Files
cyclone/backend/tests/test_dashboard_kpis.py
Nora 59c3275adf feat(sp27): server-aggregate Dashboard KPIs so 100-row sample doesn't lie
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.

Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.

Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
  batch to avoid N+1) + one bulk Remittance lookup, Python reduce
  over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
  clamps (1..24 months, 0..50 top_n_*).

Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
  bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
  ZERO_TOTALS constant, dropped the buildMonthly helper.

Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
  matched-remit math, pending-state semantics, monthly binning,
  top-providers/top-denials sort + cap, orphan-claim defensive
  guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
  contract (configured path, unconfigured fallback, param
  passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
  QueryClientProvider + stubbed useAuth + isConfigured=false. This
  fixes 3 pre-existing Dashboard test failures (the page never had
  a QueryClient set up because useClaims/useProviders were the
  first useQuery hooks in the page).

Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
   reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
   module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
   "local import inside the function" pattern — moved inline.
2026-06-29 12:51:28 -06:00

500 lines
18 KiB
Python

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