"""Dashboard mutation-boundary coverage for ``on_kanban_task_updated``. The dashboard plugin API's priority/title/body editors write task rows with direct SQL, bypassing every ``kanban_db`` mutator — the exact gap the RFC #58548 mutation-boundary review called out. These tests verify each direct-SQL write path (single-task PATCH and bulk POST) reports through ``kanban_db.notify_task_updated`` with the right ``changed_fields``. """ from __future__ import annotations import importlib.util import sys from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from hermes_cli import kanban_db as kb from hermes_cli.plugins import get_plugin_manager def _load_plugin_router(): repo_root = Path(__file__).resolve().parents[2] plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" assert plugin_file.exists(), f"plugin file missing: {plugin_file}" spec = importlib.util.spec_from_file_location( "hermes_dashboard_plugin_kanban_task_updated_test", plugin_file, ) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod spec.loader.exec_module(mod) return mod.router @pytest.fixture def kanban_home(tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) kb.init_db() return home @pytest.fixture def client(kanban_home): app = FastAPI() app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") return TestClient(app) @pytest.fixture def captured_updates(): mgr = get_plugin_manager() events: list[dict] = [] saved = {k: list(v) for k, v in mgr._hooks.items()} mgr._hooks.setdefault("on_kanban_task_updated", []).append( lambda **kw: events.append(kw) ) try: yield events finally: mgr._hooks = saved def _make_task(title="t"): conn = kb.connect() try: return kb.create_task(conn, title=title, assignee="alice") finally: conn.close() def test_patch_priority_fires_task_updated(client, captured_updates): tid = _make_task() captured_updates.clear() r = client.patch(f"/api/plugins/kanban/tasks/{tid}", json={"priority": 5}) assert r.status_code == 200 assert len(captured_updates) == 1 kw = captured_updates[0] assert kw["task_id"] == tid assert kw["changed_fields"] == ["priority"] assert kw["board"] def test_bulk_priority_fires_task_updated_per_task(client, captured_updates): tid1 = _make_task("a") tid2 = _make_task("b") captured_updates.clear() r = client.post( "/api/plugins/kanban/tasks/bulk", json={"ids": [tid1, tid2], "priority": 3}, ) assert r.status_code == 200 assert all(entry["ok"] for entry in r.json()["results"]) fired = {kw["task_id"]: kw for kw in captured_updates} assert set(fired) == {tid1, tid2} assert all(kw["changed_fields"] == ["priority"] for kw in fired.values())