CUST-WP-0061-T01: intake work-record entity (stage 3)
Fresh hub entity per the founder-reviewed decision (not a suggestions
rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md,
lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed).
- api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7
generator (48-bit ms timestamp, version/variant bits, random remainder);
existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new
work-record entities per the identity-layering canon
- api/models/intake.py: Intake + IntakeNote ORM models, mirroring
Decision's shape (topic/workplan/repo scope, lane, status, outcome,
promoted_to back-link); CHECK constraints enforce scope-required,
closed-requires-outcome, promoted-requires-promoted_to at the DB level
- migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types
- api/routers/intake.py: list/create/get/patch + /route + /close + /notes
actions, mirroring decisions.py's pattern (409 on invalid transitions,
progress event on close)
- api/schemas/intake.py: Pydantic create/update/route/close/note schemas
- mcp_server/server.py: create_intake, list_intakes, route_intake,
close_intake tool wrappers
- tests/test_intake.py: 12 tests against the real Postgres test DB
(create/list/scope-validation, full lifecycle incl. 409s and the
promoted-requires-promoted_to constraint, notes, UUIDv7 verification)
Verified live against the running dev API + DB (not just pytest): applied
the migration, restarted the MCP server, and ran a full create -> route ->
close cycle over the real REST endpoints. No regressions: full existing
suite (test_routers_core, test_suggestions, test_mcp_smoke,
test_mcp_write_tools, test_mcp_registration, test_consistency_check,
test_consistency_sweep) all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00
|
|
|
"""Tests for the `intake` work-record entity (CUST-WP-0061-T01, work-record
|
|
|
|
|
stage 3). Real PostgreSQL test database, no mocking — matches
|
|
|
|
|
tests/test_routers_core.py conventions.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_intake(client, topic_id=None, workplan_id=None, repo_id=None,
|
|
|
|
|
title="Qonto MCP mailing", lane="green", **extra):
|
|
|
|
|
payload = {"title": title, "lane": lane, **extra}
|
|
|
|
|
if topic_id is not None:
|
|
|
|
|
payload["topic_id"] = topic_id
|
|
|
|
|
if workplan_id is not None:
|
|
|
|
|
payload["workplan_id"] = workplan_id
|
|
|
|
|
if repo_id is not None:
|
|
|
|
|
payload["repo_id"] = repo_id
|
|
|
|
|
r = await client.post("/intakes/", json=payload)
|
|
|
|
|
assert r.status_code == 201, r.text
|
|
|
|
|
return r.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntakeCreateAndRead:
|
|
|
|
|
async def test_create_requires_a_scope(self, client):
|
|
|
|
|
r = await client.post("/intakes/", json={"title": "orphan intake", "lane": "green"})
|
|
|
|
|
assert r.status_code == 422
|
|
|
|
|
|
|
|
|
|
async def test_create_with_topic_scope(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
body = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
assert body["status"] == "open"
|
|
|
|
|
assert body["lane"] == "green"
|
|
|
|
|
assert body["outcome"] is None
|
|
|
|
|
|
2026-08-22 12:23:43 +02:00
|
|
|
async def test_create_preserves_explicit_authoritative_id(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
expected = "11111111-1111-4111-8111-111111111111"
|
|
|
|
|
r = await client.post(
|
|
|
|
|
"/intakes/",
|
|
|
|
|
json={"id": expected, "title": "file intake", "topic_id": topic["id"]},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 201
|
|
|
|
|
assert r.json()["id"] == expected
|
|
|
|
|
|
CUST-WP-0061-T01: intake work-record entity (stage 3)
Fresh hub entity per the founder-reviewed decision (not a suggestions
rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md,
lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed).
- api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7
generator (48-bit ms timestamp, version/variant bits, random remainder);
existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new
work-record entities per the identity-layering canon
- api/models/intake.py: Intake + IntakeNote ORM models, mirroring
Decision's shape (topic/workplan/repo scope, lane, status, outcome,
promoted_to back-link); CHECK constraints enforce scope-required,
closed-requires-outcome, promoted-requires-promoted_to at the DB level
- migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types
- api/routers/intake.py: list/create/get/patch + /route + /close + /notes
actions, mirroring decisions.py's pattern (409 on invalid transitions,
progress event on close)
- api/schemas/intake.py: Pydantic create/update/route/close/note schemas
- mcp_server/server.py: create_intake, list_intakes, route_intake,
close_intake tool wrappers
- tests/test_intake.py: 12 tests against the real Postgres test DB
(create/list/scope-validation, full lifecycle incl. 409s and the
promoted-requires-promoted_to constraint, notes, UUIDv7 verification)
Verified live against the running dev API + DB (not just pytest): applied
the migration, restarted the MCP server, and ran a full create -> route ->
close cycle over the real REST endpoints. No regressions: full existing
suite (test_routers_core, test_suggestions, test_mcp_smoke,
test_mcp_write_tools, test_mcp_registration, test_consistency_check,
test_consistency_sweep) all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00
|
|
|
async def test_get_unknown_intake_404s(self, client):
|
|
|
|
|
r = await client.get("/intakes/00000000-0000-0000-0000-000000000000")
|
|
|
|
|
assert r.status_code == 404
|
|
|
|
|
|
|
|
|
|
async def test_list_filters_by_topic(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
other_topic = await _create_topic(client, slug="other-topic", title="Other")
|
|
|
|
|
await _create_intake(client, topic_id=topic["id"], title="in scope")
|
|
|
|
|
await _create_intake(client, topic_id=other_topic["id"], title="out of scope")
|
|
|
|
|
|
|
|
|
|
r = await client.get("/intakes/", params={"topic_id": topic["id"]})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
titles = [i["title"] for i in r.json()]
|
|
|
|
|
assert titles == ["in scope"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntakeLifecycle:
|
|
|
|
|
async def test_route_moves_open_to_routed(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
|
|
|
|
|
r = await client.post(f"/intakes/{intake['id']}/route", json={"routed_note": "green lane, ready"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["status"] == "routed"
|
|
|
|
|
assert r.json()["routed_note"] == "green lane, ready"
|
|
|
|
|
|
|
|
|
|
async def test_route_from_closed_is_rejected(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
await client.post(f"/intakes/{intake['id']}/close", json={"outcome": "declined"})
|
|
|
|
|
|
|
|
|
|
r = await client.post(f"/intakes/{intake['id']}/route", json={})
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
|
|
|
|
|
async def test_close_declined_does_not_require_promoted_to(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
|
|
|
|
|
r = await client.post(f"/intakes/{intake['id']}/close", json={"outcome": "declined"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["status"] == "closed"
|
|
|
|
|
assert body["outcome"] == "declined"
|
|
|
|
|
assert body["closed_at"] is not None
|
|
|
|
|
|
|
|
|
|
async def test_close_promoted_without_promoted_to_is_rejected(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
|
|
|
|
|
r = await client.post(f"/intakes/{intake['id']}/close", json={"outcome": "promoted"})
|
|
|
|
|
assert r.status_code == 422
|
|
|
|
|
|
|
|
|
|
async def test_close_promoted_with_promoted_to_succeeds(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"], title="AWQ-010")
|
|
|
|
|
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/intakes/{intake['id']}/close",
|
|
|
|
|
json={"outcome": "promoted", "promoted_to": "BINKY-WP-0005"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["outcome"] == "promoted"
|
|
|
|
|
assert body["promoted_to"] == "BINKY-WP-0005"
|
|
|
|
|
|
|
|
|
|
async def test_close_already_closed_is_rejected(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
await client.post(f"/intakes/{intake['id']}/close", json={"outcome": "declined"})
|
|
|
|
|
|
|
|
|
|
r = await client.post(f"/intakes/{intake['id']}/close", json={"outcome": "absorbed"})
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntakeNotes:
|
|
|
|
|
async def test_add_note_appears_on_intake(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/intakes/{intake['id']}/notes",
|
|
|
|
|
json={"content": "vetted, looks real", "author": "agt-rhythm-bridge"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 201
|
|
|
|
|
notes = r.json()["notes"]
|
|
|
|
|
assert len(notes) == 1
|
|
|
|
|
assert notes[0]["content"] == "vetted, looks real"
|
|
|
|
|
assert notes[0]["author"] == "agt-rhythm-bridge"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntakeUUIDv7:
|
|
|
|
|
async def test_id_is_uuidv7(self, client):
|
|
|
|
|
await _create_domain(client)
|
|
|
|
|
topic = await _create_topic(client)
|
|
|
|
|
intake = await _create_intake(client, topic_id=topic["id"])
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
u = uuid.UUID(intake["id"])
|
|
|
|
|
assert u.version == 7
|