diff --git a/tests/conftest.py b/tests/conftest.py index 5d51ab8..270f93c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,10 +47,10 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di "title": title, "body": f"Corps de l'issue {number} en **markdown**.", "state": state, - "labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [], + "labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [], "assignee": None, "assignees": [], - "milestone": None, + "milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None, "due_date": None, "created_at": "2026-09-01T09:00:00Z", "updated_at": "2026-09-02T09:00:00Z", @@ -79,6 +79,10 @@ class FakeGitea: _issue(5, "Tâche focus"), ], } + self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}} + self.label_ops: list[tuple] = [] + self._label_seq = 100 + self.cache_invalidations = 0 self.state_changes: list[tuple] = [] self.created: list[tuple] = [] self.comments: dict[tuple, list[dict]] = { @@ -150,6 +154,51 @@ class FakeGitea: self.posted_comments.append((owner, repo, number, body)) return comment + def invalidate_cache(self): + self.cache_invalidations += 1 + + async def get_labels(self, owner, repo): + return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values()) + + async def create_label(self, owner, repo, name, color): + self._label_seq += 1 + label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")} + self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label + self.label_ops.append(("create", f"{owner}/{repo}", name)) + return label + + async def ensure_label(self, owner, repo, name, color): + existing = self.repo_labels.setdefault(f"{owner}/{repo}", {}) + if name in existing: + return existing[name] + return await self.create_label(owner, repo, name, color) + + async def add_issue_label(self, owner, repo, number, label_id): + issue = await self.get_issue(owner, repo, number) + label = next( + l for l in self.repo_labels[f"{owner}/{repo}"].values() + if l["id"] == label_id + ) + if label not in issue["labels"]: + issue["labels"].append(label) + self.label_ops.append(("add", number, label["name"])) + + async def remove_issue_label(self, owner, repo, number, label_id): + issue = await self.get_issue(owner, repo, number) + removed = [l for l in issue["labels"] if l["id"] == label_id] + issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id] + for label in removed: + self.label_ops.append(("remove", number, label["name"])) + + async def get_milestones(self, owner, repo, state="open"): + return [ + { + "id": 1, "title": "Sprint 1", + "due_on": "2026-10-20T00:00:00Z", + "open_issues": 1, "closed_issues": 1, + } + ] + async def create_issue(self, owner, repo, title, body=""): self._next_number += 1 issue = _issue(self._next_number, title) diff --git a/tests/test_palier2.py b/tests/test_palier2.py new file mode 100644 index 0000000..54539d5 --- /dev/null +++ b/tests/test_palier2.py @@ -0,0 +1,202 @@ +"""Palier 2 (0.4.0): webhooks + SSE live, label sync, milestone boards.""" + +import asyncio +import hashlib +import hmac +import json + +from app import models +from app.main import hub +from tests.conftest import csrf_from + + +def _move(client, number, to_column, from_column): + token = csrf_from(client) + return client.post( + "/api/move", + data={ + "repo_full_name": "Projets/demo", "issue_number": number, + "to_column": to_column, "from_column": from_column, + "csrf_token": token, + }, + ) + + +def _webhook(client, payload, event="issues", secret=None, raw=None): + body = raw if raw is not None else json.dumps(payload).encode() + headers = {"X-Gitea-Event": event, "Content-Type": "application/json"} + if secret is not None: + headers["X-Gitea-Signature"] = hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + return client.post("/webhooks/gitea", content=body, headers=headers) + + +# ── Hub SSE ──────────────────────────────────────────────── + +def test_hub_broadcast_reaches_subscribers(): + async def scenario(): + queue = hub.subscribe() + try: + await hub.broadcast({"repo": "a/b", "action": "closed"}) + return await asyncio.wait_for(queue.get(), timeout=1) + finally: + hub.unsubscribe(queue) + + payload = asyncio.run(scenario()) + assert payload["action"] == "closed" + assert hub.subscriber_count == 0 + + +def test_sse_event_stream_frames_and_cleanup(): + # The generator is unit-tested directly: HTTP test clients cannot + # reliably tear down an infinite SSE stream (verified empirically). + from app.main import sse_event_stream + + class FakeRequest: + async def is_disconnected(self): + return False + + async def scenario(): + gen = sse_event_stream(FakeRequest()) + first = await gen.__anext__() + await hub.broadcast({"repo": "a/b", "action": "closed"}) + second = await asyncio.wait_for(gen.__anext__(), timeout=5) + await gen.aclose() + return first, second + + first, second = asyncio.run(scenario()) + assert first == "retry: 5000\n\n" + assert second.startswith("event: gitea\ndata: ") + assert '"action": "closed"' in second + assert hub.subscriber_count == 0 # finally unsubscribed + + +# ── Webhooks ─────────────────────────────────────────────── + +def test_webhook_closed_applies_transition_and_journals(client, fake_gitea): + models.move_issue("Projets/demo", 1, "todo") + payload = { + "action": "closed", + "repository": {"full_name": "Projets/demo"}, + "issue": {"number": 1}, + } + r = _webhook(client, payload) + assert r.status_code == 200 + assert r.json()["handled"] == "closed" + state = models.get_board_state("Projets/demo") + assert [c["issue_number"] for c in state["done"]] == [1] + events = models.get_events("Projets/demo") + assert events[0]["event_type"] == "closed" + assert events[0]["source"] == "webhook" + assert fake_gitea.cache_invalidations >= 1 + + # The echo of our own API moves arrives in the same state: no-op, + # no duplicate journal entry. + r = _webhook(client, payload) + assert r.json()["handled"] == "noop" + assert len(models.get_events("Projets/demo")) == 1 + + +def test_webhook_reopened_moves_back_to_backlog(client): + models.move_issue("Projets/demo", 1, "done") + r = _webhook(client, { + "action": "reopened", + "repository": {"full_name": "Projets/demo"}, + "issue": {"number": 1}, + }) + assert r.json()["handled"] == "reopened" + state = models.get_board_state("Projets/demo") + assert [c["issue_number"] for c in state["backlog"]] == [1] + + +def test_webhook_signature_enforced_when_secret_set(client, monkeypatch): + monkeypatch.setenv("KANBANTE_WEBHOOK_SECRET", "s3cret") + payload = { + "action": "closed", + "repository": {"full_name": "Projets/demo"}, + "issue": {"number": 1}, + } + assert _webhook(client, payload).status_code == 401 + assert _webhook(client, payload, secret="wrong").status_code == 401 + assert _webhook(client, payload, secret="s3cret").status_code == 200 + + +def test_webhook_invalid_json_is_400(client): + r = _webhook(client, None, raw=b"{not json") + assert r.status_code == 400 + + +def test_webhook_ignores_unrelated_events(client): + r = _webhook(client, { + "action": "created", + "repository": {"full_name": "Projets/demo"}, + "issue": {"number": 1}, + }, event="issue_comment") + assert r.status_code == 200 + assert r.json()["handled"] == "ignored" + + +# ── Synchro par étiquettes ───────────────────────────────── + +def test_label_sync_follows_column_moves(client, fake_gitea): + r = _move(client, 1, "in_progress", "backlog") + assert r.json()["label_sync"] == "ok" + assert ("create", "Projets/demo", "kanban:in_progress") in fake_gitea.label_ops + assert ("add", 1, "kanban:in_progress") in fake_gitea.label_ops + + r = _move(client, 1, "review", "in_progress") + assert r.json()["label_sync"] == "ok" + assert ("remove", 1, "kanban:in_progress") in fake_gitea.label_ops + assert ("add", 1, "kanban:review") in fake_gitea.label_ops + + r = _move(client, 1, "done", "review") + assert r.json()["label_sync"] == "ok" + assert ("remove", 1, "kanban:review") in fake_gitea.label_ops + issue = fake_gitea.issues["Projets/demo"][0] + assert not any(l["name"].startswith("kanban:") for l in issue["labels"]) + assert any(l["name"] == "bug" for l in issue["labels"]) # user's label kept + + +def test_label_sync_can_be_disabled(client, fake_gitea, monkeypatch): + monkeypatch.setenv("KANBANTE_SYNC_LABELS", "0") + r = _move(client, 1, "in_progress", "backlog") + assert r.json()["label_sync"] == "skipped" + assert fake_gitea.label_ops == [] + + +# ── Boards par milestone ─────────────────────────────────── + +def test_board_filters_by_milestone_with_progress(client): + models.move_issue("Projets/demo", 3, "done") + r = client.get("/board/Projets/demo?milestone=Sprint%201") + assert r.status_code == 200 + assert "Tâche un" in r.text # open, in Sprint 1 + assert "Tâche focus" not in r.text # open, no milestone + assert "milestone-banner" in r.text + assert "1/2 issues terminées" in r.text + assert "50%" in r.text + assert "2026-10-20" in r.text # due_on from the milestone + + +def test_board_lists_milestone_chips(client): + r = client.get("/board/Projets/demo") + assert "milestone-chip" in r.text + assert "Sprint 1" in r.text + assert "milestone=Sprint" in r.text + + +def test_board_filter_by_milestone_id(client): + r = client.get("/board/Projets/demo?milestone=1") + assert "Tâche un" in r.text + assert "Tâche focus" not in r.text + + +# ── Refonte UI : marqueurs structurels ───────────────────── + +def test_dashboard_uses_new_design_system(client): + r = client.get("/") + assert "app-header" in r.text + assert "nav-pill" in r.text + assert "stat-tile" in r.text + assert "live.js" in r.text