"""Tests for the 6 new GET endpoints.""" 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" @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.""" text = FIXTURE_837.read_text() client = TestClient(app) client.post( "/api/parse-837", files={"file": ("x.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) return client JSON = {"Accept": "application/json"} # --------------------------------------------------------------------------- # # /api/batches # --------------------------------------------------------------------------- # def test_batches_returns_empty_list_when_no_parses(client: TestClient): resp = client.get("/api/batches", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body == {"items": [], "total": 0, "returned": 0, "has_more": False} def test_batches_returns_summary_after_parse(seeded_store): resp = seeded_store.get("/api/batches", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 assert body["returned"] == 1 assert body["items"][0]["kind"] == "837p" assert body["items"][0]["inputFilename"] == "x.txt" # --------------------------------------------------------------------------- # # /api/batches/{id} # --------------------------------------------------------------------------- # def test_get_batch_by_id_returns_full_result(seeded_store): batches = seeded_store.get("/api/batches", headers=JSON).json()["items"] bid = batches[0]["id"] resp = seeded_store.get(f"/api/batches/{bid}") assert resp.status_code == 200 body = resp.json() assert "envelope" in body assert "claims" in body assert len(body["claims"]) == 2 def test_get_batch_404_when_missing(client: TestClient): resp = client.get("/api/batches/does-not-exist") assert resp.status_code == 404 # --------------------------------------------------------------------------- # # /api/claims # --------------------------------------------------------------------------- # def test_claims_returns_empty_list_when_no_parses(client: TestClient): resp = client.get("/api/claims", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body["items"] == [] assert body["total"] == 0 def test_claims_filters_by_status_submitted(seeded_store): resp = seeded_store.get("/api/claims?status=submitted", headers=JSON) assert resp.status_code == 200 body = resp.json() assert all(c["status"] == "submitted" for c in body["items"]) assert body["total"] >= 1 def test_claims_filters_by_batch_id(seeded_store): bid = seeded_store.get("/api/batches", headers=JSON).json()["items"][0]["id"] resp = seeded_store.get(f"/api/claims?batch_id={bid}", headers=JSON) assert resp.status_code == 200 body = resp.json() assert all(c["batchId"] == bid for c in body["items"]) def test_claims_sorts_by_billed_amount_desc(seeded_store): resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc", headers=JSON) assert resp.status_code == 200 body = resp.json() amounts = [c["billedAmount"] for c in body["items"]] assert amounts == sorted(amounts, reverse=True) def test_claims_pagination_limit_offset(seeded_store): resp1 = seeded_store.get("/api/claims?limit=1&offset=0", headers=JSON) body1 = resp1.json() assert body1["returned"] == 1 assert body1["has_more"] is True # --------------------------------------------------------------------------- # # /api/remittances # --------------------------------------------------------------------------- # def test_remittances_returns_empty_list_when_no_parses(client: TestClient): resp = client.get("/api/remittances", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body["items"] == [] def test_remittances_filters_by_batch_id(seeded_store): """No 835 fixture parsed yet — but the endpoint should return 200 with empty.""" resp = seeded_store.get("/api/remittances?batch_id=nope", headers=JSON) assert resp.status_code == 200 assert resp.json()["items"] == [] # --------------------------------------------------------------------------- # # /api/providers # --------------------------------------------------------------------------- # def test_providers_empty_when_no_parses(client: TestClient): resp = client.get("/api/providers", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body["items"] == [] def test_providers_returns_one_per_distinct_npi(seeded_store): resp = seeded_store.get("/api/providers", headers=JSON) assert resp.status_code == 200 body = resp.json() assert len(body["items"]) == 1 assert body["items"][0]["npi"] == "1881068062" # --------------------------------------------------------------------------- # # /api/activity # --------------------------------------------------------------------------- # def test_activity_empty_when_no_parses(client: TestClient): resp = client.get("/api/activity", headers=JSON) assert resp.status_code == 200 body = resp.json() assert body["items"] == [] def test_activity_returns_one_event_per_claim(seeded_store): resp = seeded_store.get("/api/activity", headers=JSON) assert resp.status_code == 200 body = resp.json() assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims assert all(e["kind"] == "claim_submitted" for e in body["items"]) # --------------------------------------------------------------------------- # # NDJSON streaming on the list endpoints # --------------------------------------------------------------------------- # def _assert_ndjson_envelope(resp, expected_total: int) -> list[dict]: """Common helper: parse NDJSON body, return the item payloads.""" import json as _json assert resp.status_code == 200 assert resp.headers["content-type"].startswith("application/x-ndjson"), resp.headers lines = [l for l in resp.text.split("\n") if l.strip()] events = [_json.loads(l) for l in lines] items = [e["data"] for e in events if e["type"] == "item"] summaries = [e for e in events if e["type"] == "summary"] assert len(summaries) == 1, events s = summaries[0]["data"] assert s["total"] == expected_total assert s["returned"] == len(items) return items def test_batches_ndjson_returns_parseable_lines(seeded_store): resp = seeded_store.get( "/api/batches", headers={"Accept": "application/x-ndjson"}, ) items = _assert_ndjson_envelope(resp, expected_total=1) assert len(items) == 1 assert items[0]["kind"] == "837p" def test_claims_ndjson_returns_parseable_lines(seeded_store): resp = seeded_store.get( "/api/claims", headers={"Accept": "application/x-ndjson"}, ) items = _assert_ndjson_envelope(resp, expected_total=2) assert len(items) == 2 def test_remittances_ndjson_returns_parseable_lines(seeded_store): resp = seeded_store.get( "/api/remittances", headers={"Accept": "application/x-ndjson"}, ) # 0 batches parsed, 0 remits _assert_ndjson_envelope(resp, expected_total=0) def test_providers_ndjson_returns_parseable_lines(seeded_store): resp = seeded_store.get( "/api/providers", headers={"Accept": "application/x-ndjson"}, ) items = _assert_ndjson_envelope(resp, expected_total=1) assert items[0]["npi"] == "1881068062" def test_activity_ndjson_returns_parseable_lines(seeded_store): resp = seeded_store.get( "/api/activity", headers={"Accept": "application/x-ndjson"}, ) items = _assert_ndjson_envelope(resp, expected_total=2) assert all(it["kind"] == "claim_submitted" for it in items) def test_claims_json_default_wraps_in_envelope(seeded_store): """Accept: application/json → wrapped JSON response (items/total/returned/has_more).""" resp = seeded_store.get( "/api/claims", headers={"Accept": "application/json"}, ) assert resp.status_code == 200 body = resp.json() assert set(body.keys()) == {"items", "total", "returned", "has_more"} assert body["total"] == 2 assert body["returned"] == 2 def test_claims_ndjson_wins_over_json_when_both_accepted(seeded_store): """When both NDJSON and JSON are in Accept, the explicit NDJSON hint wins.""" resp = seeded_store.get( "/api/claims", headers={"Accept": "application/x-ndjson, application/json"}, ) assert resp.headers["content-type"].startswith("application/x-ndjson") def test_claims_default_accept_returns_json(seeded_store): """No Accept header → JSON envelope (per spec 6.2: JSON is the default for list endpoints; NDJSON is the opt-in).""" resp = seeded_store.get("/api/claims") body = resp.json() assert "items" in body assert "total" in body assert "returned" in body assert "has_more" in body # --------------------------------------------------------------------------- # # /api/reconciliation/unmatched (T14) # --------------------------------------------------------------------------- # def test_get_reconciliation_unmatched_empty(client: TestClient): """With no parsed batches, both sides of the unmatched list are empty.""" resp = client.get("/api/reconciliation/unmatched") assert resp.status_code == 200 assert resp.json() == {"claims": [], "remittances": []} def test_get_reconciliation_unmatched_returns_orphans(client: TestClient): """An unmatched Claim (left) and an unmatched Remittance (right) both surface. The 837 subscriber's ``member_id="M1"`` intentionally differs from the 835 remit's ``payer_claim_control_number="PCN-OTHER"`` so the auto-matcher in ``reconcile.run`` does not pair them on ingest — we want them to land in the unmatched list so the endpoint has something to return. """ from datetime import date, datetime, timezone from decimal import Decimal from cyclone.parsers.models import ( BatchSummary, BillingProvider, ClaimHeader, ClaimOutput, Envelope, Payer, ParseResult, Subscriber, ValidationReport, ) from cyclone.parsers.models_835 import ( BatchSummary as BatchSummary835, ClaimPayment, Envelope as Envelope835, FinancialInfo, Payer835, ParseResult835, Payee835, ReassociationTrace, ) from cyclone.store import BatchRecord837, BatchRecord835 # Add a single 837 Claim. member_id="M1" so PCN-based auto-match won't pair it. co = ClaimOutput( claim_id="CLM-1", control_number="0001", transaction_date=date(2026, 6, 19), billing_provider=BillingProvider(name="Test", npi="1234567890"), subscriber=Subscriber( first_name="Jane", last_name="Doe", member_id="M1", ), payer=Payer(name="Test Payer", id="P1"), claim=ClaimHeader( claim_id="CLM-1", total_charge=Decimal("100"), frequency_code="1", place_of_service="11", ), diagnoses=[], service_lines=[], validation=ValidationReport(passed=True, errors=[], warnings=[]), raw_segments=[], ) global_store.add(BatchRecord837( id="b-837", kind="837p", input_filename="c.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=ParseResult( claims=[co], summary=BatchSummary( input_file="c.txt", control_number="0001", transaction_date=date(2026, 6, 19), total_claims=1, passed=1, failed=0, ), ), )) # Add a single 835 Remittance with PCN="PCN-OTHER" (won't auto-match "M1"). cp = ClaimPayment( payer_claim_control_number="PCN-OTHER", status_code="1", total_charge=Decimal("50"), total_paid=Decimal("50"), service_payments=[], ) global_store.add(BatchRecord835( id="b-835", kind="835", input_filename="e.txt", parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc), result=ParseResult835( envelope=Envelope835( sender_id="S", receiver_id="R", control_number="0001", transaction_date=date(2026, 6, 19), ), financial_info=FinancialInfo( handling_code="C", paid_amount=Decimal("0"), credit_debit_flag="C", payment_method=None, ), trace=ReassociationTrace( trace_type_code="1", trace_number="0001", originating_company_id="S", ), payer=Payer835(name="X", id="SKCO0"), payee=Payee835(name="Y", npi="1234567890"), claims=[cp], summary=BatchSummary835( input_file="e.txt", control_number="0001", transaction_date=date(2026, 6, 19), total_claims=1, passed=1, failed=0, ), ), )) resp = client.get("/api/reconciliation/unmatched") assert resp.status_code == 200 body = resp.json() assert len(body["claims"]) == 1 assert body["claims"][0]["id"] == "CLM-1" assert len(body["remittances"]) == 1 assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"