test(palier-1): 16 tests — journal, métriques, détail, âge, WIP (52 verts)
FakeGitea étendu (get_issue, commentaires); le test de réconciliation suit le nouveau retour de reconcile_board_state; l'assertion de version suit APP_VERSION.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user