59c3275adf
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.
500 lines
18 KiB
Python
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 |