Files
state-hub/tests/test_routers_td_ep_contrib.py
tegwick 768a8ba9c7 fix(api): normalize trailing slashes — no slash on param routes
Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).

Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:13:01 +02:00

211 lines
7.2 KiB
Python

"""
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