Files
cyclone/backend/tests/test_api_claim_acks.py
T
Nora fba594baf4 fix(sp28): align Batch.kind filter with prod ('837p' not '837')
The SP28 helpers (batch_envelope_index + lookup_claims_for_ack_set_response
+ two _batch_lookup closures in handle_ta1 + api.py) all filter
Batch.kind == "837", but prod Batch rows are persisted with
kind="837p" (lowercase p — see api.py:443 / store/records.py:58).
Result: the D10 batch-envelope index was empty on prod and zero 999 /
277CA / TA1 acks auto-linked to their claims via Pass 1 (ST02 via
batch.envelope.control_number). Pass 2 (PCN) was the only path firing,
which on this codebase matches 0 acks (Gainwell's 999 echoes the 837's
ST02, not its CLM01 — see spec §D10).

Fix: 4 production sites swapped to Batch.kind == "837p". 3 test
files updated to use the prod value (test_apply_claim_ack_links.py +
test_api_claim_acks.py + test_e2e_999_to_claim_drawer.py). All 25 SP28
tests + 20 handler tests pass. Expected prod effect: claim
t991102989o1c120d's 143 incoming 999 AK2 acks now auto-link via the
batch with envelope.control_number=991102989 (Pass 1), plus 727/1,398
total acks across all batches.
2026-07-02 12:22:29 -06:00

359 lines
12 KiB
Python

"""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="837p",
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