Files
state-hub/tests/test_routers_core.py

650 lines
25 KiB
Python

"""
Core router tests: topics, domains, workstreams, tasks, decisions, state summary.
Happy path + key error cases. All tests use a real PostgreSQL test database
(no mocking). The `client` fixture provides an httpx.AsyncClient backed by
the FastAPI ASGI app.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _create_domain(client, slug="testdomain", name="Test Domain"):
r = await client.post("/domains/", json={"slug": slug, "name": name})
assert r.status_code == 201, r.text
return r.json()
async def _create_topic(client, domain_slug="testdomain", slug="testtopic", title="Test Topic"):
r = await client.post("/topics/", json={
"slug": slug, "title": title, "domain": domain_slug,
})
assert r.status_code == 201, r.text
return r.json()
async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active", **extra):
payload = {
"topic_id": topic_id, "slug": slug, "title": title, "status": status,
}
payload.update(extra)
r = await client.post("/workstreams/", json=payload)
assert r.status_code == 201, r.text
return r.json()
async def _create_task(client, workstream_id, title="Test task"):
r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title,
})
assert r.status_code == 201, r.text
return r.json()
async def _create_repo(client, domain_slug="testdomain", slug="test-repo", local_path=None):
r = await client.post("/repos/", json={
"domain_slug": domain_slug,
"slug": slug,
"name": "Test Repo",
"local_path": str(local_path) if local_path else None,
})
assert r.status_code == 201, r.text
return r.json()
# ---------------------------------------------------------------------------
# Domain tests
# ---------------------------------------------------------------------------
class TestDomains:
async def test_create_and_list(self, client):
await _create_domain(client)
r = await client.get("/domains/")
assert r.status_code == 200
slugs = [d["slug"] for d in r.json()]
assert "testdomain" in slugs
async def test_duplicate_slug_returns_409(self, client):
await _create_domain(client)
r = await client.post("/domains/", json={"slug": "testdomain", "name": "Dupe"})
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Topic tests
# ---------------------------------------------------------------------------
class TestTopics:
async def test_create_and_get(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.get(f"/topics/{topic['id']}")
assert r.status_code == 200
assert r.json()["slug"] == "testtopic"
async def test_duplicate_slug_returns_409(self, client):
await _create_domain(client)
await _create_topic(client)
r = await client.post("/topics/", json={
"slug": "testtopic", "title": "Dupe", "domain": "testdomain",
})
assert r.status_code == 409
async def test_unknown_domain_returns_404(self, client):
r = await client.post("/topics/", json={
"slug": "x", "title": "X", "domain": "doesnotexist",
})
assert r.status_code == 404
# ---------------------------------------------------------------------------
# Workstream tests
# ---------------------------------------------------------------------------
class TestWorkstreams:
async def test_create_and_list_by_topic(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.get(f"/workstreams/?topic_id={topic['id']}")
assert r.status_code == 200
ids = [w["id"] for w in r.json()]
assert ws["id"] in ids
async def test_status_transition(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "finished"})
assert r.status_code == 200
assert r.json()["status"] == "finished"
async def test_legacy_completed_status_is_normalized(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"})
assert r.status_code == 200
assert r.json()["status"] == "finished"
async def test_filter_by_owner(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await client.patch(f"/workstreams/{ws['id']}", json={"owner": "alice"})
r = await client.get("/workstreams/?owner=alice")
assert r.status_code == 200
assert len(r.json()) == 1
assert r.json()[0]["id"] == ws["id"]
async def test_filter_by_slug(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="my-special-ws")
r = await client.get("/workstreams/?slug=my-special-ws")
assert r.status_code == 200
assert len(r.json()) == 1
async def test_workplan_index_route(self, client):
r = await client.get("/workstreams/workplan-index")
assert r.status_code == 200
assert "workstreams" in r.json()
# ---------------------------------------------------------------------------
# Task tests
# ---------------------------------------------------------------------------
class TestTasks:
async def test_create_and_list_by_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.get(f"/tasks/?workstream_id={ws['id']}")
assert r.status_code == 200
ids = [t["id"] for t in r.json()]
assert task["id"] in ids
async def test_needs_human_flag(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={
"needs_human": True,
"intervention_note": "needs review",
})
assert r.status_code == 200
assert r.json()["needs_human"] is True
async def test_needs_human_without_note_returns_422(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"needs_human": True})
assert r.status_code == 422
async def test_cancel_via_delete(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.delete(f"/tasks/{task['id']}")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
async def test_filter_by_priority(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await client.post("/tasks/", json={
"workstream_id": ws["id"], "title": "High prio", "priority": "high",
})
await client.post("/tasks/", json={
"workstream_id": ws["id"], "title": "Low prio", "priority": "low",
})
r = await client.get(f"/tasks/?workstream_id={ws['id']}&priority=high")
assert r.status_code == 200
titles = [t["title"] for t in r.json()]
assert "High prio" in titles
assert "Low prio" not in titles
@pytest.mark.parametrize("initial_status", ["proposed", "ready", "backlog"])
async def test_task_start_activates_planning_workstream(self, client, initial_status):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(
client,
topic["id"],
slug=f"{initial_status}-ws",
status=initial_status,
)
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}")
assert r.status_code == 200
assert r.json()["status"] == "active"
async def test_task_start_does_not_unblock_blocked_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked")
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}")
assert r.status_code == 200
assert r.json()["status"] == "blocked"
# ---------------------------------------------------------------------------
# Decision tests
# ---------------------------------------------------------------------------
class TestDecisions:
async def test_create_and_resolve(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Should we use X?",
"topic_id": topic["id"],
})
assert r.status_code == 201
d_id = r.json()["id"]
r2 = await client.post(f"/decisions/{d_id}/resolve", json={
"rationale": "Yes, use X.",
"decided_by": "bernd",
})
assert r2.status_code == 200
assert r2.json()["status"] == "resolved"
async def test_resolve_already_resolved_returns_409(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Already done", "topic_id": topic["id"],
})
d_id = r.json()["id"]
await client.post(f"/decisions/{d_id}/resolve", json={"rationale": "Done", "decided_by": "bernd"})
r2 = await client.post(f"/decisions/{d_id}/resolve", json={"rationale": "Again", "decided_by": "bernd"})
assert r2.status_code == 409
async def test_financial_keyword_auto_escalates(self, client):
await _create_domain(client)
topic = await _create_topic(client)
r = await client.post("/decisions/", json={
"title": "Purchase cloud credits",
"topic_id": topic["id"],
"decision_type": "pending",
})
assert r.status_code == 201
body = r.json()
assert body["status"] == "escalated"
assert body["escalation_note"]
# ---------------------------------------------------------------------------
# State summary
# ---------------------------------------------------------------------------
class TestStateSummary:
async def test_summary_returns_expected_shape(self, client):
r = await client.get("/state/summary")
assert r.status_code == 200
body = r.json()
assert "open_workstreams" in body
assert "blocking_decisions" in body
assert "blocked_tasks" in body
assert "domains" in body
async def test_summary_counts_reflect_created_data(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
# Mark as blocked so it shows in blocked_tasks
await client.patch(f"/tasks/{task['id']}",
json={"status": "blocked", "blocking_reason": "waiting on dep"})
r = await client.get("/state/summary")
body = r.json()
assert len(body["blocked_tasks"]) >= 1
async def test_summary_derives_blocked_workstream_from_flow_engine(self, client):
await _create_domain(client)
topic = await _create_topic(client)
blocked_ws = await _create_workstream(client, topic["id"], slug="blocked-ws")
dependency_ws = await _create_workstream(client, topic["id"], slug="dependency-ws")
r = await client.post(
f"/workstreams/{blocked_ws['id']}/dependencies/",
json={
"to_workstream_id": dependency_ws["id"],
"description": "Blocked until dependency completes",
},
)
assert r.status_code == 201
r = await client.get("/state/summary")
assert r.status_code == 200
body = r.json()
summaries = {item["id"]: item for item in body["open_workstreams"]}
assert summaries[blocked_ws["id"]]["status"] == "blocked"
assert summaries[blocked_ws["id"]]["blocked_reasons"][0]["id"] == "dependencies.all_complete"
assert body["totals"]["workstreams"]["blocked"] == 1
class TestFlowEndpoints:
async def test_list_flow_definitions(self, client):
r = await client.get("/flows/definitions")
assert r.status_code == 200
entity_types = {item["entity_type"] for item in r.json()}
assert {"workstream", "task", "contribution", "capability_request"} <= entity_types
async def test_get_flow_state_and_advance_workstream(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
r = await client.get(f"/flows/workstream/{ws['id']}")
assert r.status_code == 200
assert "finished" in r.json()["reachable"]
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
assert r.status_code == 200
assert r.json()["current_workstation"] == "finished"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "finished"
async def test_advance_workstream_respects_current_exit_assertions(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="exit-blocked-ws")
dependency_ws = await _create_workstream(client, topic["id"], slug="unfinished-dep")
task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
await client.post(
f"/workstreams/{ws['id']}/dependencies/",
json={
"to_workstream_id": dependency_ws["id"],
"description": "Dependency must finish first",
},
)
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
assert r.status_code == 409
assert r.json()["detail"]["blocking_assertions"][0]["id"] == "dependencies.all_complete"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
class TestReconciliationEndpoints:
async def test_classify_workstream_open_transition_write_through(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"actor": "dashboard",
"intent": "push back to backlog",
"file_backed": True,
})
assert r.status_code == 200
body = r.json()
assert body["current_status"] == "active"
assert body["target_status"] == "backlog"
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "not_attempted"
assert body["intent"] == "push back to backlog"
async def test_classify_workstream_finish_with_open_task_needs_confirmation(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
await _create_task(client, ws["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "finished",
"file_backed": True,
})
assert r.status_code == 200
body = r.json()
assert body["tasks_terminal"] is False
assert body["reconciliation_class"] == "human_confirmation"
assert "open work" in body["reason"]
async def test_classify_task_blocked_without_reason_needs_confirmation(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"file_backed": True,
"task_linked": True,
})
assert r.status_code == 200
body = r.json()
assert body["current_status"] == "todo"
assert body["reconciliation_class"] == "human_confirmation"
assert "blocking reason" in body["reason"]
async def test_classify_unknown_workstream_returns_404(self, client):
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": "00000000-0000-0000-0000-000000000001",
"target_status": "active",
})
assert r.status_code == 404
async def test_apply_workstream_write_through_patches_file_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: backlog" in wp.read_text(encoding="utf-8")
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "backlog"
async def test_apply_workstream_without_file_does_not_mutate_db(self, client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream",
"target_id": ws["id"],
"target_status": "backlog",
"file_backed": True,
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["file_backed"] is False
assert body["reconciliation_class"] == "deferred"
assert body["write_through_result"] == "not_applicable"
assert body["reconciliation_record_id"]
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
assert r.status_code == 200
messages = r.json()
assert len(messages) == 1
assert messages[0]["id"] == body["reconciliation_record_id"]
assert "Reconcile workstream state change" in messages[0]["subject"]
assert ws["id"] in messages[0]["body"]
async def test_apply_task_write_through_patches_task_block_then_db(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"actor": "dashboard",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: in_progress" in wp.read_text(encoding="utf-8")
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress"
async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path):
await _create_domain(client)
repo_root = tmp_path / "repo"
workplans = repo_root / "workplans"
workplans.mkdir(parents=True)
repo = await _create_repo(client, local_path=repo_root)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], repo_id=repo["id"])
task = await _create_task(client, ws["id"])
wp = workplans / "STATE-WP-9999-demo.md"
wp.write_text(
"---\n"
"id: STATE-WP-9999\n"
"type: workplan\n"
"title: Demo\n"
"domain: custodian\n"
"repo: state-hub\n"
"status: active\n"
f"state_hub_workstream_id: \"{ws['id']}\"\n"
"---\n\n"
"## Demo Task\n\n"
"```task\n"
"id: STATE-WP-9999-T01\n"
"status: todo\n"
"priority: high\n"
f"state_hub_task_id: \"{task['id']}\"\n"
"```\n",
encoding="utf-8",
)
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"apply": True,
})
assert r.status_code == 200, r.text
body = r.json()
assert body["reconciliation_class"] == "human_confirmation"
assert body["write_through_result"] == "not_applicable"
assert body["reconciliation_record_id"]
r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo"
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
messages = r.json()
assert len(messages) == 1
assert "blocking reason" in messages[0]["body"]