236 lines
9.2 KiB
Python
236 lines
9.2 KiB
Python
"""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}"
|
|
# Spec §3.2: matchedRemittance is `null` for an unpaired claim
|
|
# (the dominant UI render path). The matched-populated case is
|
|
# covered separately in
|
|
# test_get_claim_detail_matched_remit_inlines_summary.
|
|
assert body["matchedRemittance"] is None
|
|
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.fail(
|
|
"co_medicaid_837p.txt + co_medicaid_835.txt fixtures no longer "
|
|
"produce an unmatched pair; matched-remit tests need updating "
|
|
"(see seeded_store_with_pair docstring for the expected invariant)"
|
|
)
|
|
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.fail(
|
|
"co_medicaid_837p.txt + co_medicaid_835.txt fixtures no longer "
|
|
"produce an unmatched pair; matched-remit tests need updating "
|
|
"(see seeded_store_with_pair docstring for the expected invariant)"
|
|
)
|
|
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"]
|
|
|
|
|
|
def test_get_claim_detail_includes_line_reconciliation_slim_projection(seeded_store):
|
|
"""SP7 §5.2: claim detail exposes a `lineReconciliation` slim list
|
|
parallel to `serviceLines`, indexed by line number. The values are
|
|
strings (Decimals serialized) or nulls when the line is unmatched."""
|
|
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()
|
|
assert "lineReconciliation" in body
|
|
lr_list = body["lineReconciliation"]
|
|
assert isinstance(lr_list, list)
|
|
# No 835 was ingested in this fixture, so every line is unmatched
|
|
# on the 835 side — the default for an unpaired claim.
|
|
for row in lr_list:
|
|
assert "lineNumber" in row
|
|
assert "status" in row
|
|
assert "paid" in row
|
|
assert "adjustmentsSum" in row
|
|
# One entry per service line.
|
|
assert len(lr_list) == len(body["serviceLines"]) |