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