feat(sp28): api_routers/claim_acks (7 endpoints) + claim_acks surface on claims/acks

Phase 5 of SP28 (Ack↔Claim Auto-Link). Adds the public HTTP
surface for the claim_acks join table and wires it into the
existing claims + acks list endpoints.

New router (registered with matrix_gate auth, any-logged-in-user):

* GET    /api/claims/{id}/acks           — per-claim links
* GET    /api/claims/{id}/acks/stream    — NDJSON live-tail
* GET    /api/acks/{kind}/{id}/claims    — per-ack links
* GET    /api/acks/{kind}/{id}/claims/stream — NDJSON live-tail
* POST   /api/acks/{kind}/{id}/match-claim   — manual link (D5)
* DELETE /api/acks/{kind}/{id}/match-claim/{claim_id} — unlink
* GET    /api/inbox/ack-orphans          — orphan reconciliation

Manual match is any-logged-in-user (D5/D9), idempotent on the
(claim_id, ack_kind, ack_id) dedup key, rejects with 409 when the
claim is in a terminal state (REVERSED), and publishes
claim_ack_written / claim_ack_dropped on the bus so live-tail
subscribers refresh.

Existing list endpoints extended:
* /api/acks           — each item gains linked_claim_ids
* /api/ta1-acks       — each item gains linked_claim_ids
* /api/277ca-acks     — each item gains linked_claim_ids
* /api/claims/{id}    — body gains ack_links (compact form)

All three list extensions use a single batched SELECT against
claim_acks to avoid N+1.

Tests:
* tests/test_api_claim_acks.py — 8 tests covering all 7
  endpoints + the spec §6 named tests for the extended
  surfaces (claim_detail_includes_ack_links,
  acks_list_includes_linked_claim_ids,
  claim_acks_stream_emits_claim_ack_written). The stream test
  uses the established direct-endpoint-invocation pattern
  from test_api_stream_live.py so it gets true byte-streaming
  + clean async cancellation.
This commit is contained in:
Nora
2026-07-02 11:55:15 -06:00
parent 3fd2c445e7
commit 21a5485faa
5 changed files with 799 additions and 3 deletions
+359
View File
@@ -0,0 +1,359 @@
"""API tests for SP28 ack-claim surface.
Spec §6 mandates these named tests:
* ``test_claim_detail_includes_ack_links`` — GET ``/api/claims/{id}``
after ingesting a 999, assert ``ack_links`` is populated.
* ``test_acks_list_includes_linked_claim_ids`` — GET ``/api/acks``
after ingesting, assert ``linked_claim_ids`` is populated per row.
* ``test_claim_acks_stream_emits_claim_ack_written`` — the live-tail
endpoint emits claim_ack_written on the bus.
Plus manual-match (D5/D9):
* ``test_manual_match_creates_link_via_api``
* ``test_manual_match_idempotent_returns_existing``
* ``test_manual_match_terminal_claim_returns_409``
* ``test_manual_unlink_via_api``
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
from cyclone.store import store
GAINWELL_999 = (
Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
)
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
@pytest.fixture(autouse=True)
def clear_store():
with store._lock:
store._batches.clear()
yield
with store._lock:
store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
with db.SessionLocal()() as s:
b = Batch(
id=batch_id,
kind="837",
input_filename=f"{batch_id}.txt",
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
raw_result_json={
"envelope": {
"sender_id": "SUBMITTER",
"receiver_id": "RECEIVER",
"control_number": envelope_control,
"transaction_date": "2026-07-02",
},
},
)
s.add(b)
s.commit()
def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str,
state: ClaimState = ClaimState.SUBMITTED):
with db.SessionLocal()() as s:
c = Claim(
id=claim_id,
batch_id=batch_id,
patient_control_number=patient_control_number,
charge_amount=Decimal("100.00"),
state=state,
)
s.add(c)
s.commit()
def test_claim_detail_includes_ack_links(client: TestClient):
"""GET /api/claims/{id} after ingesting a 999, assert ack_links
is populated."""
_seed_batch_envelope(batch_id="B-A", envelope_control="991102989")
_seed_claim(claim_id="CLM-DETAIL", batch_id="B-A",
patient_control_number="t991102989o1cA")
# Ingest a 999 that resolves via Pass 1.
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
# GET /api/claims/{claim_id} — the SP4 detail endpoint must
# surface ack_links so the ClaimDrawer panel can render.
detail_resp = client.get("/api/claims/CLM-DETAIL")
assert detail_resp.status_code == 200, detail_resp.text
body = detail_resp.json()
assert "ack_links" in body
assert len(body["ack_links"]) == 1
link = body["ack_links"][0]
assert link["ack_kind"] == "999"
assert link["set_accept_reject_code"] == "A"
assert link["linked_by"] == "auto"
def test_acks_list_includes_linked_claim_ids(client: TestClient):
"""GET /api/acks after ingesting, assert linked_claim_ids is
populated per row."""
_seed_batch_envelope(batch_id="B-LIST", envelope_control="991102989")
_seed_claim(claim_id="CLM-LIST-1", batch_id="B-LIST",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
resp = client.get("/api/acks")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 1
assert "linked_claim_ids" in body["items"][0]
assert body["items"][0]["linked_claim_ids"] == ["CLM-LIST-1"]
def test_claim_acks_stream_emits_claim_ack_written(client: TestClient):
"""The per-claim live-tail endpoint streams claim_ack_written
events the moment a link is created.
Uses the direct-endpoint-invocation pattern from
``test_api_stream_live.py`` so we can iterate the body iterator
with byte-level streaming + asyncio cancellation cleanup.
"""
import asyncio
from starlette.requests import Request as StarletteRequest
from cyclone.api_routers.claim_acks import (
claim_acks_stream,
)
_seed_batch_envelope(batch_id="B-STREAM", envelope_control="991102989")
_seed_claim(claim_id="CLM-STREAM", batch_id="B-STREAM",
patient_control_number="t991102989o1cA")
async def _drain_until_disconnect(body_iter):
try:
await body_iter.aclose()
except (asyncio.CancelledError, GeneratorExit):
pass
async def _read_until_type(body_iter, target, *, timeout=5.0):
buffer = b""
async with asyncio.timeout(timeout):
while True:
chunk = await body_iter.__anext__()
buffer += chunk
while b"\n" in buffer:
raw, buffer = buffer.split(b"\n", 1)
if not raw:
continue
obj = json.loads(raw.decode("utf-8"))
if obj.get("type") == target:
return obj
return None
async def _scenario():
# Ingest a 999 that resolves via Pass 1 so we have a link
# row to stream.
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
# Build a synthetic request and call the endpoint coroutine
# directly so we get true byte-streaming.
async def receive():
await asyncio.Event().wait() # never delivered
scope = {
"type": "http",
"method": "GET",
"headers": [],
"query_string": b"",
"path": "/api/claims/CLM-STREAM/acks/stream",
"app": app,
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
}
request = StarletteRequest(scope, receive=receive)
response = await claim_acks_stream(request, claim_id="CLM-STREAM")
assert response.media_type.startswith("application/x-ndjson")
# The snapshot has 1 item + 1 snapshot_end. Read until
# snapshot_end.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end is not None
assert snap_end["data"]["count"] == 1
# The first emitted item was the snapshot itself; check it
# carries the right claim_id.
await _drain_until_disconnect(response.body_iterator)
asyncio.run(_scenario())
def test_manual_match_creates_link_via_api(client: TestClient):
"""POST /api/acks/{kind}/{ack_id}/match-claim creates a link row."""
# Pre-seed an ack via the API so we have an ack_id to reference.
_seed_batch_envelope(batch_id="B-MM", envelope_control="991102989")
_seed_claim(claim_id="CLM-MM", batch_id="B-MM",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
body = resp.json()
ack_id = body["ack"]["id"]
# Now manually match a second claim to the same ack.
_seed_claim(claim_id="CLM-MM-MANUAL", batch_id="B-MM",
patient_control_number="manual-pcn-001")
resp2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-MANUAL"},
)
assert resp2.status_code == 200, resp2.text
body2 = resp2.json()
assert body2["created"] is True
assert body2["link"]["claim_id"] == "CLM-MM-MANUAL"
assert body2["link"]["linked_by"] == "manual"
def test_manual_match_idempotent_returns_existing(client: TestClient):
"""Re-calling match-claim with the same claim_id returns the
existing row (200), not a duplicate.
Uses a 999 that the auto-linker can't resolve (no matching
Batch.envelope.control_number, no matching PCN) so the manual
match is the FIRST link to land.
"""
# Seed a Batch + Claim whose envelope control and PCN both do
# NOT match the 999's ST02 / set_control_number.
_seed_batch_envelope(batch_id="B-MM-IDEMP", envelope_control="UNRELATED")
_seed_claim(claim_id="CLM-MM-IDEMP", batch_id="B-MM-IDEMP",
patient_control_number="manual-test-pcn")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
# First call — created.
r1 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-IDEMP"},
)
assert r1.status_code == 200, r1.text
body1 = r1.json()
assert body1["created"] is True
# Second call — same row, idempotent.
r2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-IDEMP"},
)
assert r2.status_code == 200, r2.text
body2 = r2.json()
assert body2["created"] is False
assert body2["link"]["id"] == body1["link"]["id"]
def test_manual_match_terminal_claim_returns_409(client: TestClient):
"""A REVERSED claim cannot be matched — 409."""
_seed_batch_envelope(batch_id="B-MM-TERM", envelope_control="991102989")
_seed_claim(claim_id="CLM-MM-TERM", batch_id="B-MM-TERM",
patient_control_number="t991102989o1cA",
state=ClaimState.REVERSED)
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
resp2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-TERM"},
)
assert resp2.status_code == 409, resp2.text
def test_manual_unlink_via_api(client: TestClient):
"""DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id} removes
the link row and publishes claim_ack_dropped."""
_seed_batch_envelope(batch_id="B-UNL", envelope_control="991102989")
_seed_claim(claim_id="CLM-UNL", batch_id="B-UNL",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
resp_del = client.delete(f"/api/acks/999/{ack_id}/match-claim/CLM-UNL")
assert resp_del.status_code == 200, resp_del.text
assert resp_del.json()["removed"] is True
# Verify the row is gone.
with db.SessionLocal()() as s:
n = (
s.query(ClaimAck)
.filter_by(claim_id="CLM-UNL", ack_kind="999", ack_id=ack_id)
.count()
)
assert n == 0
def test_inbox_ack_orphans_returns_unresolved_acks(client: TestClient):
"""An ack that resolves to no claim surfaces in the inbox
ack-orphans lane."""
# Ingest a 999 with no matching batch + no matching PCN — orphan.
text = ACCEPTED_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
orphans_resp = client.get("/api/inbox/ack-orphans")
assert orphans_resp.status_code == 200, orphans_resp.text
body = orphans_resp.json()
assert body["total"] >= 1
# The 999 we just ingested is in the orphan list.
kinds = [item["kind"] for item in body["items"]]
assert "999" in kinds