test(palier-2): 13 tests — webhooks, SSE, étiquettes, milestones (65 verts)
FakeGitea: labels (repo_labels/label_ops), milestones (Sprint 1 sur issues 1 et 3), invalidate_cache compteur. Le flux SSE est testé au niveau du générateur: les clients de test HTTP ne ferment pas proprement un flux infini (vérifié empiriquement); le bout-en-bout HTTP a été validé en smoke test réel (curl + webhook).
This commit is contained in:
+51
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user