diff --git a/CLAUDE.md b/CLAUDE.md index caac33e..91bed19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,13 @@ Every Claude Code session in this repository must follow this ritual: 1. Call `add_progress_event()` to log what was done, decided, or discovered 2. If new tasks were identified, create them with `create_task()` 3. If decisions were made, record them with `record_decision()` -4. If any workplan files were written or modified this session, run: +4. If API routers or models were changed, run the test suite as a gate: + ```bash + cd state-hub && make test + ``` + Requires postgres running (`make db`) and `custodian_test` database to exist. + Create it once with: `psql -U custodian -c "CREATE DATABASE custodian_test"` +5. If any workplan files were written or modified this session, run: ```bash cd state-hub && make fix-consistency REPO=the-custodian ``` diff --git a/state-hub/Makefile b/state-hub/Makefile index 33ae222..da878eb 100644 --- a/state-hub/Makefile +++ b/state-hub/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop install-hooks install-hooks-all gitea-inventory +.PHONY: install install-cli db db-tools migrate seed api dashboard check test start clean register-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop install-hooks install-hooks-all gitea-inventory COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env @@ -41,6 +41,10 @@ dashboard: check: curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool +test: + TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \ + uv run pytest -x -q + ## COULOMBCORE host (default target for tunnel targets) COULOMBCORE ?= tegwick@92.205.130.254 TUNNEL_PORT ?= 8000 diff --git a/state-hub/api/routers/topics.py b/state-hub/api/routers/topics.py index 7ed18b4..f26bfea 100644 --- a/state-hub/api/routers/topics.py +++ b/state-hub/api/routers/topics.py @@ -40,6 +40,9 @@ async def create_topic( session: AsyncSession = Depends(get_session), ) -> Topic: domain_id = await _resolve_domain_id(body.domain, session) + existing = await session.execute(select(Topic).where(Topic.slug == body.slug)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail=f"Topic slug '{body.slug}' already exists") topic = Topic( slug=body.slug, title=body.title, diff --git a/state-hub/pyproject.toml b/state-hub/pyproject.toml index 122f4d3..bfc7b20 100644 --- a/state-hub/pyproject.toml +++ b/state-hub/pyproject.toml @@ -32,6 +32,10 @@ artifacts = ["custodian_cli.py"] [tool.uv.sources] llm-connect = { path = "/home/worsch/llm-connect", editable = true } +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + [tool.uv] dev-dependencies = [ "pytest>=8.0.0", diff --git a/state-hub/tests/conftest.py b/state-hub/tests/conftest.py new file mode 100644 index 0000000..123f2df --- /dev/null +++ b/state-hub/tests/conftest.py @@ -0,0 +1,92 @@ +""" +Shared pytest fixtures for the state-hub API test suite. + +Uses a real PostgreSQL test database (custodian_test) — never mocked. +Set TEST_DATABASE_URL to override the default. + +Schema is created/dropped once per session via psycopg2 (synchronous) to +avoid asyncpg "another operation is in progress" errors during create_all. +Tables are truncated between tests for isolation. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest +import pytest_asyncio +import sqlalchemy +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +# Make api/ importable when running pytest from state-hub/ +sys.path.insert(0, str(Path(__file__).parent.parent)) + +_ASYNC_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test", +) +_SYNC_URL = _ASYNC_URL.replace("+asyncpg", "+psycopg2") + +# --------------------------------------------------------------------------- +# Schema lifecycle (synchronous — avoids asyncpg concurrent-query errors) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session", autouse=True) +def _schema(): + """Create all tables before the session; drop them after.""" + # Import all models so metadata is fully populated + from api.models import Base # noqa: F401 + + engine = sqlalchemy.create_engine(_SYNC_URL) + Base.metadata.create_all(engine) + yield + Base.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture(autouse=True) +def _truncate(_schema): + """Truncate all tables after each test for isolation.""" + from api.models import Base + + yield + engine = sqlalchemy.create_engine(_SYNC_URL) + with engine.begin() as conn: + for table in reversed(Base.metadata.sorted_tables): + conn.execute(table.delete()) + engine.dispose() + + +# --------------------------------------------------------------------------- +# Async engine (function-scoped, shared via session factory) +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def test_engine(_schema): + engine = create_async_engine(_ASYNC_URL, echo=False) + yield engine + await engine.dispose() + + +# --------------------------------------------------------------------------- +# HTTP client +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def client(test_engine): + """AsyncClient backed by the FastAPI app with get_session overridden.""" + from api.database import get_session + from api.main import app + + factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + async def _override(): + async with factory() as session: + yield session + + app.dependency_overrides[get_session] = _override + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() diff --git a/state-hub/tests/test_mcp_smoke.py b/state-hub/tests/test_mcp_smoke.py new file mode 100644 index 0000000..7f53c1c --- /dev/null +++ b/state-hub/tests/test_mcp_smoke.py @@ -0,0 +1,208 @@ +""" +MCP server smoke tests — exercise the HTTP-level correctness of the core tools. + +Rather than testing the MCP stdio protocol (which requires a running subprocess), +these tests call the underlying FastAPI endpoints that the MCP tools wrap. +This verifies that the request/response shapes the MCP tools depend on are correct. +""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _create_domain(client, slug="mcp-domain"): + r = await client.post("/domains/", json={"slug": slug, "name": "MCP Domain"}) + assert r.status_code == 201 + return r.json() + + +async def _create_topic(client, domain_slug="mcp-domain"): + r = await client.post("/topics/", json={ + "slug": "mcp-topic", "title": "MCP Topic", "domain": domain_slug, + }) + assert r.status_code == 201 + return r.json() + + +async def _create_workstream(client, topic_id): + r = await client.post("/workstreams/", json={ + "topic_id": topic_id, "slug": "mcp-ws", "title": "MCP Workstream", + }) + assert r.status_code == 201 + return r.json() + + +# --------------------------------------------------------------------------- +# get_state_summary — GET /state/summary +# --------------------------------------------------------------------------- + +class TestGetStateSummary: + async def test_returns_valid_shape(self, client): + r = await client.get("/state/summary") + assert r.status_code == 200 + body = r.json() + # Required top-level fields + for key in ("open_workstreams", "blocking_decisions", "blocked_tasks", + "domains", "contribution_counts", "licence_risk_count"): + assert key in body, f"missing key: {key}" + + async def test_empty_db_returns_zero_counts(self, client): + r = await client.get("/state/summary") + body = r.json() + assert body["open_workstreams"] == [] + assert body["blocking_decisions"] == [] + assert body["blocked_tasks"] == [] + + +# --------------------------------------------------------------------------- +# create_task — POST /tasks/ +# --------------------------------------------------------------------------- + +class TestCreateTask: + async def test_creates_task_with_defaults(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], + "title": "MCP-created task", + }) + assert r.status_code == 201 + body = r.json() + assert body["title"] == "MCP-created task" + assert body["status"] == "todo" + assert body["priority"] == "medium" + + async def test_create_task_with_priority(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], + "title": "Urgent task", + "priority": "high", + }) + assert r.status_code == 201 + assert r.json()["priority"] == "high" + + +# --------------------------------------------------------------------------- +# update_task_status — PATCH /tasks/{id} +# --------------------------------------------------------------------------- + +class TestUpdateTaskStatus: + async def test_transition_todo_to_done(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], "title": "Task to complete", + }) + t_id = r.json()["id"] + + r2 = await client.patch(f"/tasks/{t_id}", json={"status": "done"}) + assert r2.status_code == 200 + assert r2.json()["status"] == "done" + + async def test_get_task_not_found_returns_404(self, client): + import uuid + r = await client.get(f"/tasks/{uuid.uuid4()}") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# add_progress_event — POST /progress/ +# --------------------------------------------------------------------------- + +class TestAddProgressEvent: + async def test_creates_progress_event(self, client): + await _create_domain(client) + topic = await _create_topic(client) + + r = await client.post("/progress/", json={ + "topic_id": topic["id"], + "summary": "Completed initial setup", + "event_type": "milestone", + }) + assert r.status_code == 201 + body = r.json() + assert body["summary"] == "Completed initial setup" + assert body["event_type"] == "milestone" + + async def test_progress_with_detail_dict(self, client): + await _create_domain(client) + topic = await _create_topic(client) + + r = await client.post("/progress/", json={ + "topic_id": topic["id"], + "summary": "Event with data", + "event_type": "note", + "detail": {"files_changed": 3, "lines": 42}, + }) + assert r.status_code == 201 + assert r.json()["detail"]["files_changed"] == 3 + + async def test_progress_without_topic_is_allowed(self, client): + r = await client.post("/progress/", json={ + "summary": "Freeform progress note", + "event_type": "note", + }) + assert r.status_code == 201 + + +# --------------------------------------------------------------------------- +# flag_for_human — PATCH /tasks/{id} with needs_human=True +# --------------------------------------------------------------------------- + +class TestFlagForHuman: + async def test_flag_adds_intervention_note(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], "title": "Task needing help", + }) + t_id = r.json()["id"] + + r2 = await client.patch(f"/tasks/{t_id}", json={ + "needs_human": True, + "intervention_note": "Needs security review before deploying", + }) + assert r2.status_code == 200 + body = r2.json() + assert body["needs_human"] is True + assert "security review" in body["intervention_note"] + + async def test_flag_without_note_returns_422(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], "title": "Task without note", + }) + t_id = r.json()["id"] + + r2 = await client.patch(f"/tasks/{t_id}", json={"needs_human": True}) + assert r2.status_code == 422 + + async def test_clear_flag(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"]) + r = await client.post("/tasks/", json={ + "workstream_id": ws["id"], "title": "Task to clear", + }) + t_id = r.json()["id"] + + await client.patch(f"/tasks/{t_id}", json={ + "needs_human": True, "intervention_note": "needs review", + }) + r2 = await client.patch(f"/tasks/{t_id}", json={"needs_human": False}) + assert r2.status_code == 200 + assert r2.json()["needs_human"] is False diff --git a/state-hub/tests/test_routers_core.py b/state-hub/tests/test_routers_core.py new file mode 100644 index 0000000..5002316 --- /dev/null +++ b/state-hub/tests/test_routers_core.py @@ -0,0 +1,276 @@ +""" +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"): + r = await client.post("/workstreams/", json={ + "topic_id": topic_id, "slug": slug, "title": title, + }) + 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() + + +# --------------------------------------------------------------------------- +# 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": "completed"}) + assert r.status_code == 200 + assert r.json()["status"] == "completed" + + 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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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 diff --git a/state-hub/tests/test_routers_td_ep_contrib.py b/state-hub/tests/test_routers_td_ep_contrib.py new file mode 100644 index 0000000..023d015 --- /dev/null +++ b/state-hub/tests/test_routers_td_ep_contrib.py @@ -0,0 +1,210 @@ +""" +Router tests: technical debt, extension points, contributions, SBOM ingest. +""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers (reuse domain/topic setup) +# --------------------------------------------------------------------------- + +async def _setup(client): + """Create a domain + topic; return (domain_slug, topic_id).""" + r = await client.post("/domains/", json={"slug": "testdom", "name": "Test Domain"}) + assert r.status_code == 201 + r2 = await client.post("/topics/", json={ + "slug": "testtopic", "title": "Test Topic", "domain": "testdom", + }) + assert r2.status_code == 201 + return "testdom", r2.json()["id"] + + +async def _create_repo(client, domain_slug, slug="testrepo"): + r = await client.post("/repos/", json={ + "slug": slug, + "name": "Test Repo", + "domain_slug": domain_slug, + "local_path": "/tmp/testrepo", + }) + assert r.status_code == 201, r.text + return r.json() + + +# --------------------------------------------------------------------------- +# Technical Debt +# --------------------------------------------------------------------------- + +class TestTechnicalDebt: + async def test_create_and_list(self, client): + domain_slug, _ = await _setup(client) + r = await client.post("/technical-debt/", json={ + "title": "Use UTC datetimes", "domain": domain_slug, + }) + assert r.status_code == 201 + td_id = r.json()["id"] + + r2 = await client.get(f"/technical-debt/?domain={domain_slug}") + assert r2.status_code == 200 + ids = [t["id"] for t in r2.json()] + assert td_id in ids + + async def test_status_transition(self, client): + domain_slug, _ = await _setup(client) + r = await client.post("/technical-debt/", json={ + "title": "Old debt", "domain": domain_slug, + }) + td_id = r.json()["id"] + + r2 = await client.patch(f"/technical-debt/{td_id}", json={"status": "resolved"}) + assert r2.status_code == 200 + assert r2.json()["status"] == "resolved" + + async def test_add_note(self, client): + domain_slug, _ = await _setup(client) + r = await client.post("/technical-debt/", json={ + "title": "Needs note", "domain": domain_slug, + }) + td_id = r.json()["id"] + + r2 = await client.post(f"/technical-debt/{td_id}/notes/", json={ + "step": "analysis", + "content": "Root cause identified.", + }) + assert r2.status_code == 201 + + r3 = await client.get(f"/technical-debt/{td_id}/notes/") + assert r3.status_code == 200 + assert len(r3.json()) == 1 + + +# --------------------------------------------------------------------------- +# Extension Points +# --------------------------------------------------------------------------- + +class TestExtensionPoints: + async def test_create_and_list(self, client): + domain_slug, _ = await _setup(client) + r = await client.post("/extension-points/", json={ + "domain": domain_slug, + "title": "Auth hook", + "ep_type": "hook", + }) + assert r.status_code == 201 + ep_id = r.json()["id"] + + r2 = await client.get(f"/extension-points/?domain={domain_slug}") + assert r2.status_code == 200 + ids = [e["id"] for e in r2.json()] + assert ep_id in ids + + async def test_status_transition(self, client): + domain_slug, _ = await _setup(client) + r = await client.post("/extension-points/", json={ + "domain": domain_slug, "title": "Webhook", "ep_type": "webhook", + }) + ep_id = r.json()["id"] + + r2 = await client.patch(f"/extension-points/{ep_id}", json={"status": "addressed"}) + assert r2.status_code == 200 + assert r2.json()["status"] == "addressed" + + +# --------------------------------------------------------------------------- +# Contributions +# --------------------------------------------------------------------------- + +class TestContributions: + async def test_create_draft_and_submit(self, client): + r = await client.post("/contributions/", json={ + "type": "br", + "title": "Fix null pointer", + "target_org": "anthropics", + "target_repo": "sdk", + }) + assert r.status_code == 201 + c_id = r.json()["id"] + assert r.json()["status"] == "draft" + + r2 = await client.patch(f"/contributions/{c_id}/status", json={ + "status": "submitted", + }) + assert r2.status_code == 200 + assert r2.json()["status"] == "submitted" + + async def test_invalid_transition_returns_422(self, client): + r = await client.post("/contributions/", json={ + "type": "br", + "title": "Fix crash", + "target_org": "anthropics", + "target_repo": "sdk", + }) + c_id = r.json()["id"] # starts as draft + + # draft → merged is not allowed (must go through submitted first) + r2 = await client.patch(f"/contributions/{c_id}/status", json={ + "status": "merged", + }) + assert r2.status_code == 422 + + async def test_list_by_type(self, client): + await client.post("/contributions/", json={ + "type": "br", "title": "Bug A", + "target_org": "org", "target_repo": "repo", + }) + await client.post("/contributions/", json={ + "type": "fr", "title": "Feature B", + "target_org": "org", "target_repo": "repo", + }) + + r = await client.get("/contributions/?type=br") + assert r.status_code == 200 + types = [c["type"] for c in r.json()] + assert all(t == "br" for t in types) + assert len(types) == 1 + + +# --------------------------------------------------------------------------- +# SBOM ingest +# --------------------------------------------------------------------------- + +class TestSBOM: + async def test_ingest_and_list_snapshots(self, client): + domain_slug, _ = await _setup(client) + await _create_repo(client, domain_slug) + + r = await client.post("/sbom/ingest/", json={ + "repo_slug": "testrepo", + "entries": [ + { + "package_name": "fastapi", + "package_version": "0.115.0", + "ecosystem": "python", + "license_spdx": "MIT", + "is_direct": True, + "is_dev": False, + }, + { + "package_name": "sqlalchemy", + "package_version": "2.0.0", + "ecosystem": "python", + "license_spdx": "MIT", + "is_direct": True, + "is_dev": False, + }, + ], + }) + assert r.status_code == 200 + assert r.json()["ingested"] == 2 + + r2 = await client.get("/sbom/snapshots/?repo_slug=testrepo") + assert r2.status_code == 200 + assert len(r2.json()) == 1 + + async def test_ingest_unknown_repo_returns_404(self, client): + r = await client.post("/sbom/ingest/", json={ + "repo_slug": "doesnotexist", + "entries": [], + }) + assert r.status_code == 404 diff --git a/workplans/CUST-WP-0020-test-suite.md b/workplans/CUST-WP-0020-test-suite.md index 0172fd8..5a7c9bf 100644 --- a/workplans/CUST-WP-0020-test-suite.md +++ b/workplans/CUST-WP-0020-test-suite.md @@ -3,7 +3,7 @@ id: CUST-WP-0020 type: workplan title: "State Hub — pytest Test Suite" domain: custodian -status: active +status: done owner: custodian topic_slug: custodian created: "2026-03-18" @@ -35,7 +35,7 @@ not mocking the database). Use a real PostgreSQL test database via ```task id: CUST-WP-0020-T01 -status: todo +status: done priority: high state_hub_task_id: "35a52abb-15b1-4c12-b1c6-5e321377ddfa" ``` @@ -55,7 +55,7 @@ state_hub_task_id: "35a52abb-15b1-4c12-b1c6-5e321377ddfa" ```task id: CUST-WP-0020-T02 -status: todo +status: done priority: high state_hub_task_id: "85b0b6e2-d66b-4619-8e1f-2056862a0d97" ``` @@ -73,7 +73,7 @@ Coverage targets (happy path + key error cases): ```task id: CUST-WP-0020-T03 -status: todo +status: done priority: medium state_hub_task_id: "41106482-5b1c-4ee9-8979-377595f704b9" ``` @@ -89,7 +89,7 @@ state_hub_task_id: "41106482-5b1c-4ee9-8979-377595f704b9" ```task id: CUST-WP-0020-T04 -status: todo +status: done priority: medium state_hub_task_id: "6fa30dd1-065d-4158-8ef9-ed5ff7001083" ``` @@ -105,7 +105,7 @@ just the HTTP-level correctness. Focus on: `get_state_summary`, ```task id: CUST-WP-0020-T05 -status: todo +status: done priority: low state_hub_task_id: "5d206df8-c902-4c5b-8a0b-58b600480c0f" ```