diff --git a/tests/conftest.py b/tests/conftest.py index d0018a9..5d51ab8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,10 +45,15 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di issue = { "number": number, "title": title, + "body": f"Corps de l'issue {number} en **markdown**.", "state": state, "labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [], "assignee": None, + "assignees": [], + "milestone": None, + "due_date": None, "created_at": "2026-09-01T09:00:00Z", + "updated_at": "2026-09-02T09:00:00Z", "html_url": f"http://gitea.test/Projets/demo/issues/{number}", } if pr: @@ -76,6 +81,19 @@ class FakeGitea: } self.state_changes: list[tuple] = [] self.created: list[tuple] = [] + self.comments: dict[tuple, list[dict]] = { + ("Projets/demo", 1): [ + { + "id": 1, + "body": "Premier commentaire", + "created_at": "2026-09-03T10:00:00Z", + "updated_at": "2026-09-03T10:00:00Z", + "html_url": "http://gitea.test/comment/1", + "user": {"login": "bruno", "avatar_url": ""}, + } + ], + } + self.posted_comments: list[tuple] = [] self.ping_result = True self.raise_on_search = False self.fail_state_change = False @@ -105,6 +123,33 @@ class FakeGitea: items = [i for i in items if i["state"] == state] return items + async def get_issue(self, owner, repo, number): + import httpx as _httpx + + for issue in self.issues.get(f"{owner}/{repo}", []): + if issue["number"] == number: + return issue + request = _httpx.Request("GET", "http://gitea.test") + raise _httpx.HTTPStatusError( + "404", request=request, response=_httpx.Response(404, request=request) + ) + + async def get_issue_comments(self, owner, repo, number): + return list(self.comments.get((f"{owner}/{repo}", number), [])) + + async def create_comment(self, owner, repo, number, body): + comment = { + "id": 500 + len(self.posted_comments), + "body": body, + "created_at": "2026-10-11T10:00:00Z", + "updated_at": "2026-10-11T10:00:00Z", + "html_url": "http://gitea.test/comment/new", + "user": {"login": "bruno", "avatar_url": ""}, + } + self.comments.setdefault((f"{owner}/{repo}", number), []).append(comment) + self.posted_comments.append((owner, repo, number, body)) + return comment + async def create_issue(self, owner, repo, title, body=""): self._next_number += 1 issue = _issue(self._next_number, title) @@ -129,6 +174,7 @@ def clean_db(): with models.get_db() as conn: conn.execute("DELETE FROM board_state") conn.execute("DELETE FROM project_notes") + conn.execute("DELETE FROM board_events") yield diff --git a/tests/test_api.py b/tests/test_api.py index e8898e1..903e0d0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,10 +5,12 @@ from tests.conftest import csrf_from def test_health_is_public_liveness(client): + from app.config import APP_VERSION + r = client.get("/api/health") assert r.status_code == 200 assert r.json()["status"] == "ok" - assert r.json()["version"] == "0.2.0" + assert r.json()["version"] == APP_VERSION def test_readiness_reflects_gitea(client, fake_gitea): diff --git a/tests/test_models.py b/tests/test_models.py index 665df4b..a3ab220 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -76,7 +76,8 @@ def test_reconcile_closed_to_done_and_purge_deleted(): models.move_issue("a/b", 2, "todo") models.move_issue("a/b", 3, "review") changed = models.reconcile_board_state("a/b", open_numbers={1}, closed_numbers={2}) - assert changed == {"closed_to_done": 1, "purged": 1} + assert changed["closed_to_done"] == 1 and changed["purged"] == 1 + assert changed["closed_numbers"] == [2] and changed["purged_numbers"] == [3] state = models.get_board_state("a/b") assert [c["issue_number"] for c in state["in_progress"]] == [1] assert [c["issue_number"] for c in state["done"]] == [2] diff --git a/tests/test_palier1.py b/tests/test_palier1.py new file mode 100644 index 0000000..6d2e086 --- /dev/null +++ b/tests/test_palier1.py @@ -0,0 +1,183 @@ +"""Palier 1 (0.3.0): event journal, card detail panel, aging + WIP limits.""" + +from datetime import datetime, timedelta, timezone + +from app import models +from tests.conftest import csrf_from + + +def _days_ago(days: int) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + +# ── Journal ──────────────────────────────────────────────── + +def test_log_event_and_get_events(): + models.log_event("a/b", 1, "created", to_column="backlog") + models.log_event("a/b", 1, "moved", from_column="backlog", to_column="todo") + events = models.get_events("a/b") + assert [e["event_type"] for e in events] == ["moved", "created"] + assert events[0]["from_column"] == "backlog" + + +def test_log_event_rejects_unknown_type(): + import pytest + + with pytest.raises(ValueError): + models.log_event("a/b", 1, "exploded") + + +def test_move_via_api_writes_journal(client): + token = csrf_from(client) + client.post( + "/api/move", + data={ + "repo_full_name": "Projets/demo", "issue_number": 1, + "to_column": "done", "from_column": "backlog", + "to_order": "1", "csrf_token": token, + }, + ) + types = [e["event_type"] for e in models.get_events("Projets/demo")] + assert types == ["closed", "moved"] # newest first + + +def test_same_column_reorder_does_not_log_move(client): + token = csrf_from(client) + client.post( + "/api/move", + data={ + "repo_full_name": "Projets/demo", "issue_number": 5, + "to_column": "backlog", "from_column": "backlog", + "to_order": "5,1", "csrf_token": token, + }, + ) + assert models.get_events("Projets/demo") == [] + + +def test_create_issue_logs_created_event(client): + token = csrf_from(client) + client.post( + "/api/issues", + data={ + "repo_full_name": "Projets/demo", "title": "Nouvelle", + "column": "todo", "csrf_token": token, + }, + ) + events = models.get_events("Projets/demo") + assert events and events[0]["event_type"] == "created" + assert events[0]["to_column"] == "todo" + + +def test_events_api_and_activity_page(client): + models.log_event("Projets/demo", 1, "moved", + from_column="backlog", to_column="review") + r = client.get("/api/events?repo=Projets/demo") + assert r.status_code == 200 + assert r.json()["events"][0]["event_type"] == "moved" + r = client.get("/activity") + assert r.status_code == 200 + assert "Projets/demo" in r.text + + +def test_metrics_counts_and_cycle_time(): + models.log_event("a/b", 1, "created", to_column="backlog", + created_at=_days_ago(10)) + models.log_event("a/b", 1, "moved", from_column="backlog", + to_column="in_progress", created_at=_days_ago(9)) + models.log_event("a/b", 1, "closed", to_column="done", + created_at=_days_ago(4)) + metrics = models.get_metrics("a/b", window_days=30) + assert metrics["created"] == 1 + assert metrics["closed"] == 1 + assert metrics["avg_cycle_days"] == 5.0 # in_progress (J-9) -> closed (J-4) + r = models.get_metrics("a/b", window_days=30) + assert r["cycle_samples"] == 1 + + +def test_column_entry_times_ignores_comments(): + models.log_event("a/b", 1, "moved", to_column="todo", + created_at=_days_ago(3)) + models.log_event("a/b", 1, "commented", created_at=_days_ago(0)) + times = models.column_entry_times("a/b") + assert times[1] == _days_ago(3) + + +# ── Panneau détail ───────────────────────────────────────── + +def test_issue_detail_returns_body_and_comments(client): + r = client.get("/api/issues/Projets/demo/1") + assert r.status_code == 200 + data = r.json() + assert data["issue"]["title"] == "Tâche un" + assert "markdown" in data["issue"]["body"] + assert data["comments"][0]["body"] == "Premier commentaire" + assert data["comments"][0]["user"]["login"] == "bruno" + + +def test_issue_detail_404(client): + r = client.get("/api/issues/Projets/demo/999") + assert r.status_code == 404 + + +def test_post_comment_goes_to_gitea_and_journals(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/comments", + data={"body": "Bien vu !", "csrf_token": token}, + ) + assert r.status_code == 201 + assert fake_gitea.posted_comments == [("Projets", "demo", 1, "Bien vu !")] + assert models.get_events("Projets/demo")[0]["event_type"] == "commented" + + +def test_post_comment_requires_csrf_and_body(client): + r = client.post("/api/issues/Projets/demo/1/comments", data={"body": "x"}) + assert r.status_code == 403 + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/comments", + data={"body": " ", "csrf_token": token}, + ) + assert r.status_code == 400 + + +def test_board_contains_detail_drawer(client): + r = client.get("/board/Projets/demo") + assert 'id="card-detail"' in r.text + assert "marked.min.js" in r.text and "purify.min.js" in r.text + + +# ── Vieillissement + WIP ─────────────────────────────────── + +def test_aging_badge_after_threshold(client): + models.move_issue("Projets/demo", 1, "todo") + models.log_event("Projets/demo", 1, "moved", to_column="todo", + created_at=_days_ago(12)) + r = client.get("/board/Projets/demo") + assert "age-badge" in r.text + assert "12 j" in r.text + + +def test_no_aging_badge_for_fresh_card(client): + # Every open card entered its column recently: no badge anywhere. + # (A card with no journal entry falls back to its Gitea creation + # date — the fake issues are old, so both cards get fresh entries.) + models.move_issue("Projets/demo", 1, "todo") + models.log_event("Projets/demo", 1, "moved", to_column="todo", + created_at=_days_ago(1)) + models.log_event("Projets/demo", 5, "created", to_column="backlog", + created_at=_days_ago(1)) + r = client.get("/board/Projets/demo") + assert "age-badge" not in r.text + + +def test_wip_limit_marks_column_over(client, monkeypatch): + monkeypatch.setenv("KANBANTE_WIP_LIMITS", "in_progress:1") + models.move_issue("Projets/demo", 1, "in_progress") + models.move_issue("Projets/demo", 5, "in_progress") + r = client.get("/board/Projets/demo") + assert 'data-limit="1"' in r.text + assert "2/1" in r.text + assert "over" in r.text