From 129599f0f42eec8aa1d17830f737cfce53778579 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 09:43:08 -0600 Subject: [PATCH] feat(api): GET /api/claims/{claim_id} returns full claim context --- backend/src/cyclone/api.py | 29 ++++ backend/tests/test_api_claim_detail.py | 201 +++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 backend/tests/test_api_claim_detail.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index be30e26..1ddc1e7 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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). diff --git a/backend/tests/test_api_claim_detail.py b/backend/tests/test_api_claim_detail.py new file mode 100644 index 0000000..041b110 --- /dev/null +++ b/backend/tests/test_api_claim_detail.py @@ -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"] \ No newline at end of file