Files
cyclone/src/hooks/useDashboardKpis.test.ts
T
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

126 lines
4.4 KiB
TypeScript

// @vitest-environment happy-dom
// SP27 Task 13: server-aggregated Dashboard KPIs.
//
// These tests pin the hook's contract independent of the Dashboard
// page: when the backend is wired, it calls ``api.getDashboardKpis``
// with the requested parameters and returns the resolved value; when
// the backend is not wired, it returns ``data: undefined`` so the
// Dashboard can render zero-shaped fallbacks.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { api, type DashboardKpis } from "@/lib/api";
// Mock the api module so we can control isConfigured + getDashboardKpis
// without spinning up a real backend.
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
return {
...actual,
api: {
...actual.api,
getDashboardKpis: vi.fn(),
},
};
});
// Import AFTER the mock so the hook sees the mocked api.
const { useDashboardKpis } = await import("./useDashboardKpis");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeWrapper() {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client }, children);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useDashboardKpis", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls api.getDashboardKpis and returns the resolved value when configured", async () => {
const payload: DashboardKpis = {
totals: {
count: 60295,
billed: 940143.58,
received: 59753,
outstandingAr: 880390.58,
denied: 542,
denialRate: 0.9,
pending: 60,
},
monthly: [
{ month: "2026-01", label: "Jan", count: 100, billed: 15000,
received: 12000, denied: 2, denialRate: 2.0, ar: 3000 },
],
topProviders: [
{ npi: "1234567893", label: "Cedar Park", claimCount: 200,
billed: 30000, denied: 1 },
],
topDenials: [
{ id: "C-1", patientName: "Jane Doe", billedAmount: 250,
denialReason: "Missing modifier", submissionDate: "2026-06-20T12:00:00Z" },
],
};
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue(payload);
// Override the isConfigured bit too — the hook keys off this.
(api as unknown as { isConfigured: boolean }).isConfigured = true;
const { result } = renderHook(() => useDashboardKpis({ months: 6 }), {
wrapper: makeWrapper(),
});
await waitFor(() => {
expect(result.current.data).toEqual(payload);
});
expect(api.getDashboardKpis).toHaveBeenCalledWith({ months: 6 });
expect(result.current.isError).toBe(false);
});
it("returns data: undefined when the backend is not configured", () => {
(api as unknown as { isConfigured: boolean }).isConfigured = false;
const { result } = renderHook(() => useDashboardKpis(), {
wrapper: makeWrapper(),
});
expect(result.current.data).toBeUndefined();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
// queryFn should NOT have been called — bypassed entirely.
expect(api.getDashboardKpis).not.toHaveBeenCalled();
});
it("passes top_n_providers / top_n_denials through to the API", async () => {
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue({
totals: { count: 0, billed: 0, received: 0, outstandingAr: 0,
denied: 0, denialRate: 0, pending: 0 },
monthly: [], topProviders: [], topDenials: [],
});
(api as unknown as { isConfigured: boolean }).isConfigured = true;
renderHook(
() => useDashboardKpis({ months: 3, top_n_providers: 2, top_n_denials: 5 }),
{ wrapper: makeWrapper() },
);
expect(api.getDashboardKpis).toHaveBeenCalledWith({
months: 3, top_n_providers: 2, top_n_denials: 5,
});
});
});