feat(api): GET /api/claims/{claim_id} returns full claim context

This commit is contained in:
Tyler
2026-06-20 09:43:08 -06:00
parent 7d0e76150b
commit 129599f0f4
2 changed files with 230 additions and 0 deletions
+29
View File
@@ -712,6 +712,35 @@ def list_claims(
}
@app.get("/api/claims/{claim_id}")
def get_claim_detail_endpoint(claim_id: str) -> dict:
"""Return one claim with full drawer context.
SP4 detail-drawer endpoint. The body is the spec-shaped dict
produced by :meth:`CycloneStore.get_claim_detail` — header, state,
service lines, diagnoses, parties, validation, raw segments,
``stateHistory`` (most-recent-first, capped at 50), and a
populated ``matchedRemittance`` block if the claim is paired.
Path param is ``claim_id`` (not ``id``) to avoid shadowing
FastAPI's internal ``id`` name and to keep OpenAPI docs self-
describing (matches the ``/api/acks/{ack_id}`` convention from
SP3). Returns 404 when the claim is missing — never 500 — so the
UI can distinguish "claim doesn't exist" from a transient fetch
failure.
"""
body = store.get_claim_detail(claim_id)
if body is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": f"Claim {claim_id} not found",
},
)
return body
@app.get("/api/reconciliation/unmatched")
def get_reconciliation_unmatched() -> dict:
"""Return unmatched Claims (left) and unmatched Remittances (right).
+201
View File
@@ -0,0 +1,201 @@
"""Tests for GET /api/claims/{claim_id} (SP4 Task 2).
The endpoint is a thin wrapper over
``CycloneStore.get_claim_detail`` (added in Task 1, commit 423415a),
which already returns the spec-shaped dict or ``None``. The endpoint's
job is therefore narrow:
* call the store,
* translate ``None`` to a 404 with ``{"error": "Not found",
"detail": "..."}``,
* surface the dict verbatim on a hit.
These tests pin the endpoint contract: response shape on 200, the 404
shape (matching the existing ``/api/remittances/{id}`` convention),
and two payload-level checks the rest of the SP4 work depends on —
``stateHistory`` surfaces ``manual_match`` events, and a paired claim's
``matchedRemittance`` block is populated.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
@pytest.fixture(autouse=True)
def clear_store():
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_store():
"""One parsed 837P fixture batch — drives the list endpoint."""
text_837 = FIXTURE_837.read_text()
c = TestClient(app)
c.post(
"/api/parse-837",
files={"file": ("x.txt", text_837, "text/plain")},
headers={"Accept": "application/json"},
)
return c
@pytest.fixture
def seeded_store_with_pair():
"""837 + 835 fixture pair, no auto-match (different PCNs in each).
The 837 subscriber ``member_id`` values (``P060946``, ``H582447``)
intentionally don't match the 835 ``payer_claim_control_number``
values (``t991102986o1c1d``, ``t991102986o1c2d``), so the auto-
matcher in ``reconcile.run`` leaves both sides in the unmatched
bucket — giving us a clean manual-match target.
"""
text_837 = FIXTURE_837.read_text()
text_835 = FIXTURE_835.read_text()
c = TestClient(app)
c.post(
"/api/parse-837",
files={"file": ("x.txt", text_837, "text/plain")},
headers={"Accept": "application/json"},
)
c.post(
"/api/parse-835",
files={"file": ("era.txt", text_835, "text/plain")},
headers={"Accept": "application/json"},
)
return c
JSON = {"Accept": "application/json"}
def test_get_claim_detail_happy_path(seeded_store):
"""A real claim returns 200 with all spec-mandated top-level keys."""
cid = seeded_store.get("/api/claims", headers=JSON).json()["items"][0]["id"]
resp = seeded_store.get(f"/api/claims/{cid}")
assert resp.status_code == 200, resp.text
body = resp.json()
# All keys required by the spec §3.2 (excluding only the optional
# `matchedRemittance` shape — that's covered in
# `test_get_claim_detail_matched_remit_inlines_summary`).
for k in (
"id", "batchId", "state", "stateLabel", "billedAmount",
"patientName", "providerNpi", "providerName", "payerName",
"payerId", "submissionDate", "serviceDateFrom", "serviceDateTo",
"parsedAt", "diagnoses", "serviceLines", "parties",
"validation", "rawSegments", "matchedRemittance", "stateHistory",
):
assert k in body, f"missing key {k!r}"
assert body["id"] == cid
# billedAmount is a number (not a Decimal string).
assert isinstance(body["billedAmount"], (int, float))
# stateHistory must contain the claim_submitted event seeded at
# 837 ingest time — this is the baseline other history events are
# appended to.
kinds = [ev["kind"] for ev in body["stateHistory"]]
assert "claim_submitted" in kinds, kinds
def test_get_claim_detail_404_for_missing(client):
"""Missing claim returns 404 (not 500) with the same envelope as
``get_remittance`` so the UI can render a single 404 path."""
resp = client.get("/api/claims/DOES-NOT-EXIST")
assert resp.status_code == 404
body = resp.json()
# FastAPI wraps the HTTPException(detail=dict) under .detail.
detail = body.get("detail", body)
assert detail.get("error") == "Not found"
assert "DOES-NOT-EXIST" in detail.get("detail", "")
def test_get_claim_detail_state_history_includes_manual_match(seeded_store_with_pair):
"""After /api/reconciliation/match, stateHistory surfaces manual_match.
The fixture pair leaves both sides in the unmatched bucket (837
subscriber member_ids don't match 835 PCNs), so the manual-match
API has clean work to do. After matching, the claim's
``stateHistory`` must include both the baseline ``claim_submitted``
event (from 837 ingest) and the new ``manual_match`` event (from
the match call), and the manual_match event must carry the
``remittanceId`` it was paired with.
"""
unmatched = seeded_store_with_pair.get(
"/api/reconciliation/unmatched", headers=JSON,
).json()
if not unmatched["claims"] or not unmatched["remittances"]:
pytest.skip("seeded fixtures don't produce an unmatched pair")
claim_id = unmatched["claims"][0]["id"]
remit_id = unmatched["remittances"][0]["id"]
match_resp = seeded_store_with_pair.post(
"/api/reconciliation/match",
json={"claim_id": claim_id, "remit_id": remit_id},
headers=JSON,
)
assert match_resp.status_code == 200, match_resp.text
resp = seeded_store_with_pair.get(f"/api/claims/{claim_id}")
assert resp.status_code == 200, resp.text
body = resp.json()
kinds = [ev["kind"] for ev in body["stateHistory"]]
assert "manual_match" in kinds, body["stateHistory"]
assert "claim_submitted" in kinds, body["stateHistory"]
# The manual_match event must carry the remittanceId it paired
# with — the UI uses it to deep-link the drawer to the matched
# remit. The claim_submitted event must NOT carry a remittanceId.
mm = next(ev for ev in body["stateHistory"] if ev["kind"] == "manual_match")
assert mm["remittanceId"] == remit_id
cs = next(ev for ev in body["stateHistory"] if ev["kind"] == "claim_submitted")
assert cs["remittanceId"] is None
def test_get_claim_detail_matched_remit_inlines_summary(seeded_store_with_pair):
"""A paired claim surfaces a populated ``matchedRemittance`` block.
The drawer renders this block at the top of the right column;
the spec §3.2 requires ``id``, ``totalPaid`` (number), ``status``
(one of ``received``/``reconciled``), and ``receivedAt`` (ISO
ending in ``Z``). This pins the inlined shape so a UI refactor
can't drift from it.
"""
unmatched = seeded_store_with_pair.get(
"/api/reconciliation/unmatched", headers=JSON,
).json()
if not unmatched["claims"] or not unmatched["remittances"]:
pytest.skip("seeded fixtures don't produce an unmatched pair")
claim_id = unmatched["claims"][0]["id"]
remit_id = unmatched["remittances"][0]["id"]
seeded_store_with_pair.post(
"/api/reconciliation/match",
json={"claim_id": claim_id, "remit_id": remit_id},
headers=JSON,
)
resp = seeded_store_with_pair.get(f"/api/claims/{claim_id}")
assert resp.status_code == 200, resp.text
mr = resp.json()["matchedRemittance"]
assert mr is not None
assert mr["id"] == remit_id
assert isinstance(mr["totalPaid"], (int, float))
assert mr["status"] in {"received", "reconciled"}
# ISO timestamp must end in Z — the spec contract that lets the
# UI render the time without a tz suffix shim.
assert mr["receivedAt"].endswith("Z"), mr["receivedAt"]