From 028bbc5425fd170330a841f64ac312efd2b6e1b8 Mon Sep 17 00:00:00 2001 From: wuli666 <49897769+wuli666@users.noreply.github.com> Date: Mon, 18 May 2026 20:12:46 -0700 Subject: [PATCH] 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 --- tests/plugins/test_kanban_dashboard_plugin.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 55524534899..24596b9a943 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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():