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:
@@ -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
|
||||
Reference in New Issue
Block a user