test(kanban-dashboard): cover _task_dict task_age fallback

The fix in 061a1830 added an outer try/except in plugin_api._task_dict
so that a future failure mode in kanban_db.task_age (anything _safe_int
doesn't already absorb) cannot 500 the GET /board response. The
_safe_int / task_age corruption paths got regression coverage in
tests/hermes_cli/test_kanban_db.py, but the OUTER fallback contract
remained untested -- meaning a refactor that drops the try/except would
not be caught by CI.

Pin that contract from both consumers of _task_dict:
- GET /board returns 200 with the literal fallback age dict for the
  affected card (other cards continue to render via the same path)
- GET /tasks/:id (drawer view) returns 200 with the same fallback,
  so a single corrupt task can't block its own drawer

Both tests force task_age to raise RuntimeError rather than ValueError
on '%s', because ValueError is absorbed by _safe_int and never reaches
the outer try/except -- testing that path would only re-cover what
test_kanban_db.py already pins.

Manually verified the regression discipline:
  git checkout 061a1830^ -- plugins/kanban/dashboard/plugin_api.py
  pytest -k task_age_exception        # both FAIL with 500
  git checkout HEAD -- plugins/kanban/dashboard/plugin_api.py
  pytest -k task_age_exception        # both PASS
This commit is contained in:
wuli666 2026-05-18 20:12:46 -07:00 committed by Teknium
parent f01ee0b575
commit 028bbc5425

View file

@ -1193,6 +1193,87 @@ def test_create_task_no_warning_on_triage(client, monkeypatch):
assert "warning" not in r.json() or not r.json()["warning"]
# ---------------------------------------------------------------------------
# _task_dict — outer try/except fallback when task_age raises
#
# Background: kanban_db.task_age was hardened in 061a1830 to return None for
# corrupt timestamp values via _safe_int. The companion fix added a belt-and-
# suspenders try/except in plugin_api._task_dict so that *any future* exception
# from task_age (not just ValueError on '%s') still yields a usable dict
# instead of 500'ing GET /board for the entire org.
#
# kanban_db._safe_int / task_age corruption paths are covered in
# tests/hermes_cli/test_kanban_db.py. The OUTER fallback here is not, which
# means a refactor that drops the try/except would not be caught by CI. The
# tests below pin that contract.
# ---------------------------------------------------------------------------
_FALLBACK_AGE = {
"created_age_seconds": None,
"started_age_seconds": None,
"time_to_complete_seconds": None,
}
def test_board_endpoint_survives_task_age_exception(client, monkeypatch):
"""If task_age raises for any reason, GET /board must NOT 500.
Pre-fix behavior (without the try/except in _task_dict): a single corrupt
row turned the entire board response into a 500. The fallback dict lets
the dashboard render every other card normally.
"""
create = client.post(
"/api/plugins/kanban/tasks",
json={"title": "doomed", "assignee": "alice"},
)
assert create.status_code == 200, create.text
# Force task_age to raise an exception type _safe_int does NOT handle —
# simulates a future regression where someone re-introduces an unguarded
# operation in task_age. ValueError on '%s' would be absorbed by _safe_int
# and never reach the outer try/except, so it would not exercise the
# contract this test pins.
def _boom(_task):
raise RuntimeError("simulated future task_age bug")
monkeypatch.setattr("hermes_cli.kanban_db.task_age", _boom)
r = client.get("/api/plugins/kanban/board")
assert r.status_code == 200, r.text
payload = r.json()
# /board returns columns as a list of {name, tasks} — not a dict — so
# flatten across all columns to find our seeded task.
tasks = [t for col in payload["columns"] for t in col["tasks"]]
assert len(tasks) == 1, f"expected exactly the seeded task, got {tasks!r}"
# Strict equality: the literal fallback dict from plugin_api._task_dict
# is the published contract the dashboard UI relies on. Key renames or
# silent additions should fail this test on purpose.
assert tasks[0]["age"] == _FALLBACK_AGE
def test_single_task_endpoint_survives_task_age_exception(client, monkeypatch):
"""GET /tasks/:id also calls _task_dict — same fallback should kick in.
This is the "drawer view" path: the user clicks one card and we serialize
just that task. A corrupt timestamp on a single task should not block the
user from opening its drawer.
"""
create = client.post(
"/api/plugins/kanban/tasks",
json={"title": "drawer-target", "assignee": "bob"},
)
task_id = create.json()["task"]["id"]
def _boom(_task):
raise RuntimeError("simulated future task_age bug")
monkeypatch.setattr("hermes_cli.kanban_db.task_age", _boom)
r = client.get(f"/api/plugins/kanban/tasks/{task_id}")
assert r.status_code == 200, r.text
assert r.json()["task"]["age"] == _FALLBACK_AGE
def test_create_task_probe_error_does_not_break_create(client, monkeypatch):
"""Probe failure must never break task creation."""
def _raise():