feat(api): GET /api/claims/{claim_id} returns full claim context
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user