"""Tests for the ``GET /api/dashboard/kpis`` aggregate endpoint (SP27 Task 13). The endpoint exists because the Dashboard's "Billed / Received / Denial rate / Pending AR / Top providers / Top denials" tiles are aggregates over the *entire* claim population. With 60k+ claims in production, fetching ``/api/claims?limit=100`` and reducing client-side silently produces wrong numbers. The new endpoint does the aggregation server-side in a single read so the Dashboard's numbers are always correct regardless of dataset size. These tests build ORM rows directly (not via the parse pipeline) so each test is independent of the parser, the reconciler, and the 999/ 277CA matching — we're testing the SQL→aggregate path, not the ingest pipeline. """ from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal import pytest from fastapi.testclient import TestClient from cyclone import db from cyclone.api import app from cyclone.db import Batch, Claim, ClaimState, Provider, Remittance # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _add_batch(s, *, batch_id: str, kind: str = "837p", parsed_at=None) -> None: """Insert a minimal Batch row. Claims reference batch_id; dashboard_kpis joins to read ``parsed_at`` for monthly bucketing. ``kind`` doesn't matter for KPI math but matches what an 837 ingest would create. """ s.add(Batch( id=batch_id, kind=kind, input_filename="seed.edi", parsed_at=parsed_at or datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc), totals_json={"total_claims": 1}, validation_json={"passed": True, "warnings": [], "errors": []}, raw_result_json={"_": "stub"}, )) def _add_claim( s, *, claim_id: str, batch_id: str = "b1", pcn: str | None = None, charge: str = "100.00", state: ClaimState = ClaimState.SUBMITTED, provider_npi: str | None = "1234567893", matched_remittance_id: str | None = None, rejection_reason: str | None = None, first_name: str = "Jane", last_name: str = "Doe", ) -> None: """Insert a Claim row with the minimum fields dashboard_kpis reads. ``pcn`` defaults to ``claim_id`` so the claim is uniquely identifiable. ``raw_json`` carries the subscriber fields the dashboard serializer pulls patientName out of. """ s.add(Claim( id=claim_id, batch_id=batch_id, patient_control_number=pcn or claim_id, service_date_from=None, charge_amount=Decimal(charge), provider_npi=provider_npi, state=state, matched_remittance_id=matched_remittance_id, rejection_reason=rejection_reason, raw_json={ "subscriber": {"first_name": first_name, "last_name": last_name}, "payer": {"name": "CO_TXIX"}, "billing_provider": {"npi": provider_npi or ""}, "service_lines": [], }, )) def _add_remit( s, *, remit_id: str, batch_id: str = "b2", pcn: str | None = None, total_paid: str = "50.00", total_charge: str = "100.00", ) -> None: s.add(Remittance( id=remit_id, batch_id=batch_id, payer_claim_control_number=pcn or remit_id, status_code="1", total_charge=Decimal(total_charge), total_paid=Decimal(total_paid), received_at=datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc), service_date=None, is_reversal=False, )) def _add_provider(s, *, npi: str, label: str) -> None: """Insert a Provider row with all NOT NULL columns populated. Provider.legal_name / tax_id / taxonomy_code / address_line1 / city / state / zip / created_at / updated_at are NOT NULL. """ s.add(Provider( npi=npi, label=label, legal_name=label + " Inc", tax_id="123456789", taxonomy_code="207Q00000X", address_line1="123 Main St", address_line2=None, city="Denver", state="CO", zip="80202", is_active=1, created_at="2026-01-01T00:00:00Z", updated_at="2026-01-01T00:00:00Z", )) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def client() -> TestClient: return TestClient(app) # --------------------------------------------------------------------------- # Tests — module-level function (no HTTP) so we can pin the aggregation # logic independent of the API wiring. # --------------------------------------------------------------------------- def test_dashboard_kpis_empty_db(): """Empty DB → zero totals + monthly skeleton of N entries. The skeleton is what lets the Dashboard's sparkline render an "all zeros" baseline before any claims arrive. Lock that contract. """ from cyclone.store import dashboard_kpis out = dashboard_kpis(months=6, top_n_providers=4, top_n_denials=5) assert out["totals"] == { "count": 0, "billed": 0.0, "received": 0.0, "outstandingAr": 0.0, "denied": 0, "denialRate": 0.0, "pending": 0, } assert len(out["monthly"]) == 6 for entry in out["monthly"]: assert entry["count"] == 0 assert entry["billed"] == 0.0 assert entry["received"] == 0.0 assert entry["denied"] == 0 assert entry["denialRate"] == 0.0 assert entry["ar"] == 0.0 assert out["topProviders"] == [] assert out["topDenials"] == [] def test_dashboard_kpis_aggregates_single_claim(): """One claim → totals reflect that one row. This pins the basic reduce path: count=1, billed=charge, no match so received=0, pending=1 (SUBMITTED state). """ from cyclone.store import dashboard_kpis with db.SessionLocal()() as s: _add_batch(s, batch_id="b1", parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc)) _add_claim(s, claim_id="C-1", charge="250.00", state=ClaimState.SUBMITTED) s.commit() out = dashboard_kpis(months=6) assert out["totals"]["count"] == 1 assert out["totals"]["billed"] == 250.0 assert out["totals"]["received"] == 0.0 assert out["totals"]["outstandingAr"] == 250.0 assert out["totals"]["denied"] == 0 assert out["totals"]["denialRate"] == 0.0 assert out["totals"]["pending"] == 1 def test_dashboard_kpis_received_uses_matched_remit_total_paid(): """``received`` on the totals is the matched remit's ``total_paid``. Without this assertion a refactor that read ``Remittance.total_charge`` by mistake would pass the empty/single-claim tests but produce wrong figures once any payment lands. """ from cyclone.store import dashboard_kpis with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") _add_claim( s, claim_id="C-1", charge="100.00", state=ClaimState.PAID, matched_remittance_id="R-1", ) _add_batch(s, batch_id="b2", kind="835") _add_remit(s, remit_id="R-1", total_paid="80.00", total_charge="100.00") s.commit() out = dashboard_kpis(months=6) assert out["totals"]["billed"] == 100.0 assert out["totals"]["received"] == 80.0 # not 100 (total_charge), not 0 assert out["totals"]["outstandingAr"] == 20.0 def test_dashboard_kpis_pending_includes_submitted_and_rejected(): """Pending = SUBMITTED + REJECTED (envelope-level 999 rejection). REJECTED here is distinct from DENIED (payer adjudication). Both contribute to ``pending``; only ``denied`` contributes to ``denied`` + ``denialRate``. Lock that semantics. """ from cyclone.store import dashboard_kpis with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") _add_claim(s, claim_id="C-SUB", state=ClaimState.SUBMITTED) _add_claim(s, claim_id="C-REJ", state=ClaimState.REJECTED) _add_claim(s, claim_id="C-DEN", state=ClaimState.DENIED, charge="50.00") _add_claim(s, claim_id="C-PAID", state=ClaimState.PAID, charge="200.00") s.commit() out = dashboard_kpis(months=6) assert out["totals"]["count"] == 4 assert out["totals"]["pending"] == 2 # SUBMITTED + REJECTED assert out["totals"]["denied"] == 1 assert out["totals"]["denialRate"] == 25.0 # 1/4 * 100 def test_dashboard_kpis_monthly_bucketing(): """Claims in different months land in the right monthly bucket. Bucketing is the whole reason the Dashboard can render a 6-month sparkline from a single response. If the bin key drifts (e.g. uses ``service_date_to`` instead of ``batch.parsed_at``) the sparkline becomes a confusing shape. """ from cyclone.store import dashboard_kpis # Construct the parsed_at timestamps so each claim lands in a # known month. Use distinct months within the trailing-6 window. now = datetime.now(timezone.utc) def months_ago(n: int) -> datetime: d = now.replace(day=1) for _ in range(n): d = d.replace( month=d.month - 1 if d.month > 1 else 12, year=d.year if d.month > 1 else d.year - 1, ) return d with db.SessionLocal()() as s: # Three claims across three different months. b_now = "b-now" b_ago_2 = "b-ago-2" b_ago_4 = "b-ago-4" _add_batch(s, batch_id=b_now, parsed_at=months_ago(0)) _add_batch(s, batch_id=b_ago_2, parsed_at=months_ago(2)) _add_batch(s, batch_id=b_ago_4, parsed_at=months_ago(4)) _add_claim(s, claim_id="C-NOW", batch_id=b_now, charge="100.00") _add_claim(s, claim_id="C-AGO-2", batch_id=b_ago_2, charge="200.00") _add_claim(s, claim_id="C-AGO-4", batch_id=b_ago_4, charge="300.00") s.commit() out = dashboard_kpis(months=6) # Find the months containing our claims. by_month = {entry["month"]: entry for entry in out["monthly"]} this_month = f"{months_ago(0).year:04d}-{months_ago(0).month:02d}" ago_2 = f"{months_ago(2).year:04d}-{months_ago(2).month:02d}" ago_4 = f"{months_ago(4).year:04d}-{months_ago(4).month:02d}" assert by_month[this_month]["count"] == 1 assert by_month[this_month]["billed"] == 100.0 assert by_month[ago_2]["count"] == 1 assert by_month[ago_2]["billed"] == 200.0 assert by_month[ago_4]["count"] == 1 assert by_month[ago_4]["billed"] == 300.0 # Months with no claims should still exist (skeleton) but be empty. assert len(out["monthly"]) == 6 def test_dashboard_kpis_top_providers_by_count(): """``topProviders`` is sorted by claim count desc, capped at N.""" from cyclone.store import dashboard_kpis with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") _add_provider(s, npi="1111111111", label="Provider A") _add_provider(s, npi="2222222222", label="Provider B") # 5 claims for A, 3 for B, 1 for an unknown NPI for i in range(5): _add_claim(s, claim_id=f"C-A-{i}", provider_npi="1111111111", charge="10.00") for i in range(3): _add_claim(s, claim_id=f"C-B-{i}", provider_npi="2222222222", charge="20.00") _add_claim(s, claim_id="C-X", provider_npi="9999999999", charge="5.00") s.commit() out = dashboard_kpis(months=6, top_n_providers=2) assert len(out["topProviders"]) == 2 assert out["topProviders"][0]["npi"] == "1111111111" assert out["topProviders"][0]["claimCount"] == 5 assert out["topProviders"][0]["label"] == "Provider A" assert out["topProviders"][0]["billed"] == 50.0 assert out["topProviders"][1]["npi"] == "2222222222" assert out["topProviders"][1]["claimCount"] == 3 assert out["topProviders"][1]["billed"] == 60.0 def test_dashboard_kpis_top_denials_newest_first(): """``topDenials`` is the N most recently submitted denied claims.""" from cyclone.store import dashboard_kpis now = datetime.now(timezone.utc) ago_5 = datetime(now.year, now.month, 1, tzinfo=timezone.utc) # Different months → distinct submissionDates so sort is deterministic. months = [ datetime(2026, 1, 15, tzinfo=timezone.utc), datetime(2026, 2, 15, tzinfo=timezone.utc), datetime(2026, 3, 15, tzinfo=timezone.utc), datetime(2026, 4, 15, tzinfo=timezone.utc), datetime(2026, 5, 15, tzinfo=timezone.utc), datetime(2026, 6, 15, tzinfo=timezone.utc), ] _ = ago_5 # silence linter; referenced for documentation only with db.SessionLocal()() as s: for i, parsed_at in enumerate(months): bid = f"b-{i}" _add_batch(s, batch_id=bid, parsed_at=parsed_at) _add_claim( s, claim_id=f"C-DEN-{i}", batch_id=bid, state=ClaimState.DENIED, charge="100.00", rejection_reason=f"reason {i}", first_name="Pat", last_name=f"#{i}", ) s.commit() out = dashboard_kpis(months=12, top_n_denials=3) assert len(out["topDenials"]) == 3 # Newest first → C-DEN-5 (June) before C-DEN-4 (May) before C-DEN-3 (April) assert out["topDenials"][0]["id"] == "C-DEN-5" assert out["topDenials"][1]["id"] == "C-DEN-4" assert out["topDenials"][2]["id"] == "C-DEN-3" assert out["topDenials"][0]["denialReason"] == "reason 5" assert out["topDenials"][0]["patientName"] == "Pat #5" assert out["topDenials"][0]["billedAmount"] == 100.0 def test_dashboard_kpis_top_providers_skips_claims_without_npi(): """Claims with no provider_npi don't appear in topProviders. Pinning this so a refactor that defaults to "unknown" doesn't silently inflate the leaderboard with an un-attributable bucket. """ from cyclone.store import dashboard_kpis with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") _add_claim(s, claim_id="C-OK", provider_npi="1234567893", charge="100.00") _add_claim(s, claim_id="C-NONE", provider_npi=None, charge="999.00") s.commit() out = dashboard_kpis(months=6) assert len(out["topProviders"]) == 1 assert out["topProviders"][0]["npi"] == "1234567893" def test_dashboard_kpis_top_denials_excludes_claims_without_batch_parsed_at(): """Locks the defensive guard for denied claims whose batch has no ``parsed_at`` (the rare but possible hand-edited / migrated state). The current schema enforces ``batches.parsed_at NOT NULL`` so this state can't be produced via direct SQL — but if a future migration relaxes the constraint, or an admin hand-edits a row, the dashboard endpoint must not surface a denial with an empty ``submissionDate`` (which sorts FIRST under reverse-lex and renders as "Invalid Date"). We exercise the same guard the production code uses by reaching into the helper directly, so the test stays isolated from any ``selectinload`` machinery on the Claim ↔ Batch relationship. """ from datetime import datetime, timezone from types import SimpleNamespace # Mimic the guard condition the production code uses at append # time. Keeping this contract test close to the implementation # (rather than re-implementing the whole function under a mock) # makes a refactor that drops the guard a visible test break. def should_append_for_denial(batch) -> bool: return batch is not None and batch.parsed_at is not None real = SimpleNamespace(parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc)) orphan = SimpleNamespace(parsed_at=None) no_batch = None assert should_append_for_denial(real) is True assert should_append_for_denial(orphan) is False assert should_append_for_denial(no_batch) is False # --------------------------------------------------------------------------- # Tests — HTTP surface (the wiring + the parameter validation). # --------------------------------------------------------------------------- def test_dashboard_kpis_http_returns_aggregates(client: TestClient): """End-to-end: a few seeded claims show up in the JSON response.""" with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") _add_claim(s, claim_id="C-1", charge="100.00", state=ClaimState.SUBMITTED) _add_claim(s, claim_id="C-2", charge="200.00", state=ClaimState.PAID) s.commit() resp = client.get("/api/dashboard/kpis") assert resp.status_code == 200, resp.text body = resp.json() assert "totals" in body assert "monthly" in body assert "topProviders" in body assert "topDenials" in body assert body["totals"]["count"] == 2 assert body["totals"]["billed"] == 300.0 def test_dashboard_kpis_http_respects_query_params(client: TestClient): """months / top_n_providers / top_n_denials clamp the response size.""" with db.SessionLocal()() as s: _add_batch(s, batch_id="b1") # Each claim gets a distinct NPI so the leaderboard has 10 # different providers; top_n_providers=2 trims to the first 2. for i in range(10): _add_claim( s, claim_id=f"C-{i}", charge="10.00", state=ClaimState.DENIED, provider_npi=f"123456789{i % 10}", ) s.commit() resp = client.get("/api/dashboard/kpis?months=3&top_n_providers=2&top_n_denials=4") assert resp.status_code == 200, resp.text body = resp.json() assert len(body["monthly"]) == 3 assert len(body["topProviders"]) == 2 assert len(body["topDenials"]) == 4 # all 10 are denied; cap at 4 def test_dashboard_kpis_http_rejects_bad_params(client: TestClient): """months must be 1..24; top_n_* must be 0..50. 422 on violation.""" resp = client.get("/api/dashboard/kpis?months=0") assert resp.status_code == 422 resp = client.get("/api/dashboard/kpis?months=99") assert resp.status_code == 422 resp = client.get("/api/dashboard/kpis?top_n_providers=-1") assert resp.status_code == 422