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