22720168c4
Phase 5 Task 5.8 (PartiesGrid) and 5.9 (ValidationPanel) added useDrillStack()
calls, so Claims.test.tsx needs a DrillStackProvider wrapper around the
page render to keep the hook happy. Task 5.10 refactored the ClaimDrawer
header onto the shared DrillDrawerHeader, so the deep-link and close-button
tests need to find the title via <h2> and the close button via
aria-label='Close drawer' instead of the now-removed header-id /
header-close testids.
Without this fix the page-level Claims tests report 3 failures
(deep link, URL clear after close, ?-mark with drawer closed) that
were not failing on the Phase 4 base (9a313d2).
783 lines
28 KiB
TypeScript
783 lines
28 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level
|
|
// keyboard listener. Both paths need an act-aware environment or React
|
|
// logs noisy warnings. Mirror the convention from ClaimDrawer.test.tsx.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { Claims } from "./Claims";
|
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
|
import { api } from "@/lib/api";
|
|
import { useTailStore } from "@/store/tail-store";
|
|
import type { Claim, ClaimDetail } from "@/types";
|
|
|
|
// Module-level mock — vitest hoists vi.mock above imports. We mock the
|
|
// two API methods the test path actually touches:
|
|
//
|
|
// - `api.listClaims` is called by `useClaims` (via TanStack Query) so
|
|
// the table has data to render rows.
|
|
// - `api.getClaimDetail` is called by `useClaimDetail` from inside the
|
|
// ClaimDrawer once the URL has `?claim=…`.
|
|
//
|
|
// Same shape as ClaimDrawer.test.tsx; we just add `listClaims` to the
|
|
// mock object so the page-under-test populates its table.
|
|
vi.mock("@/lib/api", () => {
|
|
class ApiError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
return {
|
|
api: {
|
|
isConfigured: true,
|
|
listClaims: vi.fn(),
|
|
getClaimDetail: vi.fn(),
|
|
},
|
|
ApiError,
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests).
|
|
//
|
|
// We mock `useTailStream` directly (per the plan's "latter is simpler"
|
|
// guidance) so the page-level tests don't have to drive the real
|
|
// `streamTail` parser or the real AbortController-based reconnect loop.
|
|
// `useTailStream` is exercised exhaustively in its own test file
|
|
// (`useTailStream.test.ts`); the page only needs to observe that the
|
|
// returned status flows through to `<TailStatusPill>` and that items
|
|
// pushed into the tail store surface as rows via `useMergedTail`.
|
|
//
|
|
// The mock returns `status: "live"` by default — that matches what the
|
|
// real hook settles to after a `snapshot_end` event arrives, which is
|
|
// the state the page sees in production ~all of the time.
|
|
// ---------------------------------------------------------------------------
|
|
vi.mock("@/hooks/useTailStream", () => ({
|
|
useTailStream: vi.fn(() => ({
|
|
status: "live",
|
|
lastEventAt: null,
|
|
error: null,
|
|
forceReconnect: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
const SAMPLE_CLAIMS: Claim[] = [
|
|
{
|
|
id: "CLM-1",
|
|
patientName: "Jane Doe",
|
|
providerNpi: "1234567890",
|
|
payerName: "Colorado Medicaid",
|
|
cptCode: "99213",
|
|
billedAmount: 123.45,
|
|
receivedAmount: 0,
|
|
status: "submitted",
|
|
submissionDate: "2026-06-15",
|
|
},
|
|
{
|
|
id: "CLM-2",
|
|
patientName: "John Smith",
|
|
providerNpi: "1234567890",
|
|
payerName: "Aetna",
|
|
cptCode: "99214",
|
|
billedAmount: 200,
|
|
receivedAmount: 200,
|
|
status: "paid",
|
|
submissionDate: "2026-06-16",
|
|
},
|
|
];
|
|
|
|
const SAMPLE_DETAIL: ClaimDetail = {
|
|
id: "CLM-1",
|
|
batchId: "B-1",
|
|
state: "submitted",
|
|
stateLabel: "Submitted",
|
|
billedAmount: 123.45,
|
|
patientName: "Jane Doe",
|
|
providerNpi: "1234567890",
|
|
providerName: "Acme Clinic",
|
|
payerName: "Colorado Medicaid",
|
|
payerId: "MEDCO",
|
|
submissionDate: "2026-06-15T00:00:00Z",
|
|
serviceDateFrom: null,
|
|
serviceDateTo: null,
|
|
parsedAt: "2026-06-15T00:00:01Z",
|
|
diagnoses: [],
|
|
serviceLines: [],
|
|
parties: {
|
|
billingProvider: {
|
|
name: "Acme Clinic",
|
|
npi: "1234567890",
|
|
taxId: "12-3456789",
|
|
address: {
|
|
line1: "123 Main St",
|
|
line2: null,
|
|
city: "Denver",
|
|
state: "CO",
|
|
zip: "80202",
|
|
},
|
|
},
|
|
subscriber: {
|
|
firstName: "Jane",
|
|
lastName: "Doe",
|
|
memberId: "M-1",
|
|
dob: null,
|
|
gender: "F",
|
|
},
|
|
payer: { name: "Colorado Medicaid", id: "MEDCO" },
|
|
},
|
|
validation: { passed: true, errors: [], warnings: [] },
|
|
rawSegments: [],
|
|
matchedRemittance: null,
|
|
stateHistory: [],
|
|
};
|
|
|
|
/**
|
|
* Point happy-dom at a known URL. happy-dom exposes `setURL` on
|
|
* `window.happyDOM` (a non-standard helper for tests) — copying the
|
|
* pattern from `useDrawerUrlState.test.ts` so the two files stay
|
|
* consistent.
|
|
*/
|
|
function setLocation(url: string): void {
|
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
|
}
|
|
|
|
/**
|
|
* Render harness. Mirrors the pattern from Acks/Remittances test files:
|
|
* a real DOM container wrapped in `QueryClientProvider` so the page's
|
|
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
|
* `api.listClaims`.
|
|
*
|
|
* Also wraps in a `MemoryRouter` so the page's `useSearchParams`
|
|
* (added when Claims started reading `?status=` / `?sort=` off the URL
|
|
* for Dashboard KPI drill-through) has a router context to read from.
|
|
* The default `initialEntries` mirrors whatever `setLocation(...)` set
|
|
* on `window.location`, so the existing tests' pre-set URLs keep
|
|
* working without per-test changes.
|
|
*/
|
|
function renderClaims(): { unmount: () => void } {
|
|
const initialEntries = [
|
|
window.location.pathname + window.location.search,
|
|
];
|
|
return renderClaimsAt(initialEntries);
|
|
}
|
|
|
|
/**
|
|
* Variant of `renderClaims` that lets a test pin a specific initial
|
|
* URL. Used by the URL-param filter tests below so each one can start
|
|
* at exactly the URL it's asserting on.
|
|
*/
|
|
function renderClaimsAt(initialEntries: string[]): {
|
|
unmount: () => void;
|
|
container: HTMLDivElement;
|
|
} {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const qc = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
// Default retry delay is exponential backoff — way too slow for
|
|
// unit tests. Zero keeps the happy path fast.
|
|
retryDelay: 0,
|
|
},
|
|
},
|
|
});
|
|
const root: Root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
React.createElement(
|
|
QueryClientProvider,
|
|
{ client: qc },
|
|
React.createElement(
|
|
MemoryRouter,
|
|
{ initialEntries },
|
|
// SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by
|
|
// Claims now uses useDrillStack() in PartiesGrid +
|
|
// ValidationPanel. Wrap in a DrillStackProvider so the
|
|
// hook has a context (the provider is also mounted at
|
|
// the App root in production).
|
|
React.createElement(
|
|
DrillStackProvider,
|
|
null,
|
|
React.createElement(Claims),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
return {
|
|
container,
|
|
unmount: () => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Flush microtasks + react state updates until `predicate` holds (or we
|
|
* time out). react-query's fetch resolves asynchronously, then Radix
|
|
* portals the drawer into document.body — both need a tick before the
|
|
* DOM reflects the new state.
|
|
*/
|
|
async function settle(
|
|
predicate: () => boolean,
|
|
timeoutMs = 2000
|
|
): Promise<void> {
|
|
const start = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error("settle: predicate did not hold within timeout");
|
|
}
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("Claims page drawer wiring", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset URL to bare claims page between tests.
|
|
setLocation("http://localhost/claims");
|
|
(api.listClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
items: SAMPLE_CLAIMS,
|
|
total: SAMPLE_CLAIMS.length,
|
|
returned: SAMPLE_CLAIMS.length,
|
|
has_more: false,
|
|
});
|
|
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
SAMPLE_DETAIL
|
|
);
|
|
// Live-tail slice is a singleton zustand store — clear it between
|
|
// tests so the live-arrival test doesn't see rows from a previous
|
|
// case.
|
|
useTailStore.getState().reset("claims");
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Defensive: leave the URL clean for the next test file.
|
|
setLocation("http://localhost/claims");
|
|
});
|
|
|
|
it("test_clicking_a_row_opens_the_drawer", async () => {
|
|
const { unmount } = renderClaims();
|
|
|
|
// Wait for the table to populate from the mocked api.listClaims.
|
|
await settle(
|
|
() => document.body.textContent?.includes("CLM-1") ?? false
|
|
);
|
|
|
|
// Drawer must not be open yet — no `?claim=` in URL means
|
|
// ClaimDrawer renders null (per its early-return when claimId is
|
|
// null).
|
|
expect(document.body.querySelector('[data-testid="claim-drawer"]')).toBeNull();
|
|
|
|
// Click the first row (the one showing CLM-1). The page wires
|
|
// `onClick={() => open(c.id)}` to each row, so this should
|
|
// (a) push `?claim=CLM-1` into the URL, and
|
|
// (b) cause the ClaimDrawer to mount in document.body.
|
|
//
|
|
// Note: the id cell renders "CLM-1" plus a sibling "CPT 99213" line,
|
|
// so the row's textContent is "CLM-1CPT 99213…". Match by substring
|
|
// instead of exact equality.
|
|
const row = Array.from(document.querySelectorAll("tbody tr")).find(
|
|
(r) => r.textContent?.includes("CLM-1")
|
|
) as HTMLTableRowElement | undefined;
|
|
expect(row).toBeDefined();
|
|
await act(async () => {
|
|
row!.click();
|
|
});
|
|
|
|
// URL side effect — happy-dom's pushState updates window.location,
|
|
// so this assertion is end-to-end against the real hook.
|
|
expect(window.location.href).toContain("?claim=CLM-1");
|
|
|
|
// Drawer mounts in a Radix portal under document.body, so we poll
|
|
// for it.
|
|
await settle(
|
|
() => document.body.querySelector('[data-testid="claim-drawer"]') !== null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
|
).not.toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_deep_link_with_?claim=opens_drawer_on_mount", async () => {
|
|
// Pre-set the URL so the hook reads CLM-1 off `window.location.search`
|
|
// during its `useState` initializer — no click needed.
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
renderClaims();
|
|
|
|
// Drawer should appear immediately, without user interaction.
|
|
await settle(
|
|
() => document.body.querySelector('[data-testid="claim-drawer"]') !== null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
|
).not.toBeNull();
|
|
|
|
// SP21 Phase 5 Task 5.10: the claim id is now the title of the
|
|
// shared DrillDrawerHeader (rendered as an <h2>). Find the h2
|
|
// inside the drawer and assert its text matches the claim id —
|
|
// proves the id propagated end-to-end (URL → useDrawerUrlState
|
|
// → ClaimDrawer prop → useClaimDetail → header render), not
|
|
// just that some drawer is mounted.
|
|
await settle(() => {
|
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
|
const h2 = drawer?.querySelector("h2");
|
|
return h2?.textContent === "CLM-1";
|
|
});
|
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
|
expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1");
|
|
});
|
|
|
|
it("test_escape_closes_the_drawer", async () => {
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
renderClaims();
|
|
|
|
await settle(
|
|
() => document.body.querySelector('[data-testid="claim-drawer"]') !== null
|
|
);
|
|
|
|
// Press Escape on `window` — useDrawerKeyboard (called from inside
|
|
// ClaimDrawer) listens there. Page wires onClose → useDrawerUrlState's
|
|
// close() → pushState + setClaimIdState(null).
|
|
await act(async () => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
);
|
|
});
|
|
|
|
// Drawer should unmount (ClaimDrawer returns null when claimId is null).
|
|
await settle(
|
|
() => document.body.querySelector('[data-testid="claim-drawer"]') === null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
|
).toBeNull();
|
|
// URL must no longer carry ?claim=.
|
|
expect(window.location.href).not.toContain("?claim=");
|
|
});
|
|
|
|
it("test_url_clears_after_close", async () => {
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
renderClaims();
|
|
|
|
// Wait for the header to render — the close button only mounts once
|
|
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
|
|
// error states don't render the header). SP21 Phase 5 Task 5.10:
|
|
// the close button is now inside the shared DrillDrawerHeader;
|
|
// find it via its accessible name.
|
|
await settle(
|
|
() =>
|
|
document.body.querySelector(
|
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
|
) !== null
|
|
);
|
|
|
|
// URL currently carries the claim.
|
|
expect(window.location.href).toContain("?claim=CLM-1");
|
|
|
|
// Click the X close button in the header. The page wires this to
|
|
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
|
// the ?claim= param stripped.
|
|
const closeBtn = document.body.querySelector(
|
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
|
) as HTMLButtonElement | null;
|
|
expect(closeBtn).not.toBeNull();
|
|
await act(async () => {
|
|
closeBtn!.click();
|
|
});
|
|
|
|
// URL must no longer carry ?claim=.
|
|
await settle(() => !window.location.href.includes("?claim="));
|
|
expect(window.location.href).not.toContain("?claim=");
|
|
});
|
|
|
|
it("test_question_mark_toggles_cheatsheet", async () => {
|
|
// The cheatsheet keystroke is wired through `useDrawerKeyboard`
|
|
// (which lives inside ClaimDrawer and only enables when
|
|
// `claimId !== null`). Deep-link with `?claim=CLM-1` so the
|
|
// drawer mounts and the keyboard listener is active.
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
const { unmount } = renderClaims();
|
|
|
|
// Drawer must be open before we test the toggle.
|
|
await settle(
|
|
() => document.body.querySelector('[data-testid="claim-drawer"]') !== null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
|
).not.toBeNull();
|
|
|
|
// Cheatsheet starts closed.
|
|
expect(
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
|
).toBeNull();
|
|
|
|
// First `?` press: useDrawerKeyboard's handler flips `helpOpen`
|
|
// true on the page; the cheatsheet portals into document.body.
|
|
await act(async () => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "?", bubbles: true })
|
|
);
|
|
});
|
|
await settle(
|
|
() =>
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
|
null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
|
).not.toBeNull();
|
|
|
|
// Second `?` press: same handler flips `helpOpen` back to false;
|
|
// the cheatsheet unmounts. (The cheatsheet's own window-level
|
|
// listener explicitly returns early on `?` — it never pre-empts
|
|
// the parent's toggle, by design.)
|
|
await act(async () => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "?", bubbles: true })
|
|
);
|
|
});
|
|
await settle(
|
|
() =>
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
|
|
null
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
|
).toBeNull();
|
|
|
|
// Unmount so the next test (which asserts the drawer is absent)
|
|
// doesn't see this test's still-open drawer leaking into
|
|
// document.body.
|
|
unmount();
|
|
});
|
|
|
|
it("test_question_mark_does_nothing_when_drawer_closed", async () => {
|
|
// The `?` keystroke is owned by `useDrawerKeyboard`, which only
|
|
// attaches its `window` listener when `claimId !== null`. With no
|
|
// `?claim=` in the URL, the drawer is closed and `?` is a no-op
|
|
// — the cheatsheet must not appear.
|
|
setLocation("http://localhost/claims");
|
|
renderClaims();
|
|
|
|
// Wait for the table to populate (so the page is fully mounted).
|
|
await settle(
|
|
() => document.body.textContent?.includes("CLM-1") ?? false
|
|
);
|
|
expect(
|
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
|
).toBeNull();
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "?", bubbles: true })
|
|
);
|
|
});
|
|
|
|
// No cheatsheet should be present — and not just "not yet":
|
|
// flush a tick to make sure a delayed listener couldn't have
|
|
// snuck it in.
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
});
|
|
expect(
|
|
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
|
).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Live-tail integration (sub-project 5, Phase 5 Task 22 page tests).
|
|
//
|
|
// We mock `useTailStream` at the module level (see the top of this
|
|
// file) so the page sees a settled `status: "live"` without driving
|
|
// the real streamTail parser. To exercise the "tail arrival triggers a
|
|
// row" path we push a new claim directly into `useTailStore` — this
|
|
// is exactly what the real `useTailStream` hook does on `item` events,
|
|
// so we cover the same render path (zustand notify → useMergedTail
|
|
// re-derive → `<TableRow>` mount).
|
|
// -------------------------------------------------------------------
|
|
|
|
it("test_live_tail_arrival_triggers_row_in_table", async () => {
|
|
const { unmount } = renderClaims();
|
|
|
|
// Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first.
|
|
await settle(
|
|
() => document.body.textContent?.includes("CLM-1") ?? false,
|
|
);
|
|
|
|
// The new claim id is brand new (not in base, not in tail store yet).
|
|
const TAIL_ID = "CLM-TAIL-1";
|
|
expect(
|
|
document.body.textContent?.includes(TAIL_ID) ?? false,
|
|
).toBe(false);
|
|
|
|
// Simulate a live-arriving claim — this is what `useTailStream`
|
|
// does when it dispatches an `item` event. Wrap in `act` because
|
|
// zustand subscribers re-render synchronously.
|
|
await act(async () => {
|
|
useTailStore.getState().addClaim({
|
|
id: TAIL_ID,
|
|
patientName: "Live Arrival",
|
|
providerNpi: "1234567890",
|
|
payerName: "Colorado Medicaid",
|
|
cptCode: "99215",
|
|
billedAmount: 999,
|
|
receivedAmount: 0,
|
|
status: "submitted",
|
|
submissionDate: "2026-06-20",
|
|
});
|
|
});
|
|
|
|
// The new row must appear — proves the end-to-end path
|
|
// zustand.addClaim → useTailStore notify → useMergedTail re-derive
|
|
// → <TableBody> mounts the row with the new id.
|
|
await settle(
|
|
() => document.body.textContent?.includes(TAIL_ID) ?? false,
|
|
);
|
|
expect(document.body.textContent).toContain(TAIL_ID);
|
|
// The row's secondary cells should also be present so we know the
|
|
// row fully rendered, not just that the id appeared somewhere
|
|
// (e.g. in the search-input placeholder or similar).
|
|
expect(document.body.textContent).toContain("Live Arrival");
|
|
expect(document.body.textContent).toContain("99215");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_status_pill_shows_live_after_stream_connects", async () => {
|
|
const { unmount } = renderClaims();
|
|
|
|
// Wait for the base table to mount — once it's there, the toolbar
|
|
// (which contains the pill) has also mounted.
|
|
await settle(
|
|
() => document.body.textContent?.includes("CLM-1") ?? false,
|
|
);
|
|
|
|
// The mocked `useTailStream` returns `status: "live"`, so the
|
|
// <TailStatusPill> should render with the human label "Live"
|
|
// (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the
|
|
// pill's textContent so this stays robust against future styling
|
|
// changes (the test doesn't depend on Tailwind classes).
|
|
const pill = document.body.querySelector(
|
|
'[data-testid="tail-status-pill"]',
|
|
);
|
|
expect(pill).not.toBeNull();
|
|
expect(pill?.textContent).toContain("Live");
|
|
|
|
unmount();
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up).
|
|
//
|
|
// Dashboard KPI tiles drill through to Claims with `?status=` and
|
|
// `?sort=` query params. These tests pin each URL and assert that
|
|
// the page actually filters (not just lands on a default view).
|
|
//
|
|
// The strongest assertion is on `api.listClaims.mock.calls` — that's
|
|
// the exact params the page forwards to the API, so a passing test
|
|
// proves end-to-end that URL → useSearchParams → useClaims → fetch.
|
|
// -------------------------------------------------------------------
|
|
|
|
it("test_url_param_status_denied_filters_and_selects_chip", async () => {
|
|
const { unmount, container } = renderClaimsAt(["/claims?status=denied"]);
|
|
|
|
// The first render of FilterChips is reached before useSearchParams
|
|
// has the URL-derived `status` value committed (MemoryRouter
|
|
// initializes synchronously but the chip's aria-checked only flips
|
|
// to "true" on the next React commit). Wait for that commit so
|
|
// the assertion below isn't a race against the first render.
|
|
// We settle on `useClaims` having been called with status=denied
|
|
// — that's the strongest invariant: it proves the URL → state →
|
|
// API-call chain has fully propagated end-to-end.
|
|
await settle(
|
|
() => {
|
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
|
const lastCallParams = calls[calls.length - 1]?.[0] as
|
|
| { status?: string; sort?: string; order?: string }
|
|
| undefined;
|
|
return lastCallParams?.status === "denied";
|
|
},
|
|
);
|
|
|
|
// Scope queries to this test's container so we can't accidentally
|
|
// pick up a chip from a leaked render of a previous test.
|
|
const deniedChip = Array.from(
|
|
container.querySelectorAll('[role="radio"]'),
|
|
).find(
|
|
(el) => el.textContent?.trim().toLowerCase() === "denied",
|
|
) as HTMLButtonElement | undefined;
|
|
expect(deniedChip).toBeDefined();
|
|
expect(deniedChip?.getAttribute("aria-checked")).toBe("true");
|
|
|
|
// No other status chip should be checked.
|
|
const checkedChips = Array.from(
|
|
container.querySelectorAll('[role="radio"][aria-checked="true"]'),
|
|
);
|
|
expect(checkedChips).toHaveLength(1);
|
|
expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe(
|
|
"denied",
|
|
);
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => {
|
|
const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]);
|
|
|
|
// Wait for useClaims to be called once.
|
|
await settle(
|
|
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
|
);
|
|
|
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
|
const lastCallParams = calls[calls.length - 1]?.[0] as
|
|
| { status?: string; sort?: string; order?: string }
|
|
| undefined;
|
|
|
|
// The `-` prefix must be stripped off and order set to desc.
|
|
expect(lastCallParams?.sort).toBe("receivedAmount");
|
|
expect(lastCallParams?.order).toBe("desc");
|
|
// No status filter — user only asked for a sort change.
|
|
expect(lastCallParams?.status).toBeUndefined();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_url_param_sort_without_prefix_orders_ascending", async () => {
|
|
const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]);
|
|
|
|
await settle(
|
|
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
|
);
|
|
|
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
|
const lastCallParams = calls[calls.length - 1]?.[0] as
|
|
| { status?: string; sort?: string; order?: string }
|
|
| undefined;
|
|
|
|
// No `-` prefix → order is asc.
|
|
expect(lastCallParams?.sort).toBe("submittedDate");
|
|
expect(lastCallParams?.order).toBe("asc");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => {
|
|
// Default URL — matches the existing "click a row to open the
|
|
// drawer" tests, but here we assert on the params forwarded to
|
|
// the API to prove the no-param default matches the previous
|
|
// hardcoded behavior (sort=billedAmount, order=desc, no status).
|
|
const { unmount } = renderClaimsAt(["/claims"]);
|
|
|
|
await settle(
|
|
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
|
);
|
|
|
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
|
const lastCallParams = calls[calls.length - 1]?.[0] as
|
|
| { status?: string; sort?: string; order?: string }
|
|
| undefined;
|
|
|
|
expect(lastCallParams?.status).toBeUndefined();
|
|
expect(lastCallParams?.sort).toBe("billedAmount");
|
|
expect(lastCallParams?.order).toBe("desc");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("test_url_param_sort_dash_only_falls_back_to_default", async () => {
|
|
// `?sort=-` is the prefix-with-no-field degenerate case. A naive
|
|
// `slice(1)` would yield "" and the API would get `sort=""`.
|
|
// We assert the page falls back to the default sort instead.
|
|
const { unmount } = renderClaimsAt(["/claims?sort=-"]);
|
|
|
|
await settle(
|
|
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
|
);
|
|
|
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
|
const lastCallParams = calls[calls.length - 1]?.[0] as
|
|
| { status?: string; sort?: string; order?: string }
|
|
| undefined;
|
|
|
|
expect(lastCallParams?.sort).toBe("billedAmount");
|
|
expect(lastCallParams?.order).toBe("desc");
|
|
|
|
unmount();
|
|
});
|
|
|
|
// -------------------------------------------------------------------
|
|
// Regression for SP21 Task 2.4 — DrillableCell click bubbling.
|
|
//
|
|
// The provider cell renders a <button> (via DrillableCell) inside a
|
|
// <TableRow onClick={() => open(c.id)}>. Before the fix, clicking the
|
|
// provider button (1) navigated to /providers?provider=… and then
|
|
// (2) bubbled to the row, where open() rebuilt the URL on top of the
|
|
// new /providers URL and appended a phantom ?claim=CLM-1. The page
|
|
// mounted on /providers, but the URL was a Frankenstein of both
|
|
// nav states and the browser history was polluted with two entries
|
|
// per click.
|
|
//
|
|
// We assert the URL after a provider-cell click does NOT carry
|
|
// ?claim=. The fix lives in DrillableCell (stopPropagation), so this
|
|
// is an end-to-end check that the component-level guard closes the
|
|
// bubble all the way back at the row.
|
|
//
|
|
// Note on MemoryRouter: react-router's `navigate()` updates the
|
|
// router's internal history but does NOT push to window.history, so
|
|
// `window.location.href` in this test stays on `/claims`. That's
|
|
// still the right place to assert "no phantom claim= param landed on
|
|
// window.history" — that's where the bug manifested (buildUrl() in
|
|
// the row's open() reads window.location and pushState's the bad
|
|
// URL). In a real BrowserRouter the URL would advance to /providers
|
|
// after navigate(), and the same assertion holds: ?claim= would be
|
|
// absent.
|
|
// -------------------------------------------------------------------
|
|
it("test_clicking_provider_cell_navigates_without_pushing_claim_param", async () => {
|
|
const { unmount } = renderClaims();
|
|
|
|
// Wait for the table to populate from the mocked api.listClaims.
|
|
await settle(
|
|
() => document.body.textContent?.includes("CLM-1") ?? false,
|
|
);
|
|
|
|
// Both SAMPLE_CLAIMS use providerNpi "1234567890", which doesn't
|
|
// match any seeded sampleProvider, so the aria-label falls through
|
|
// to `View provider 1234567890`. Pick the first matching button.
|
|
const btn = Array.from(document.querySelectorAll("button")).find(
|
|
(b) =>
|
|
b.getAttribute("aria-label")?.startsWith("View provider ") ?? false,
|
|
) as HTMLButtonElement | undefined;
|
|
expect(btn).toBeDefined();
|
|
|
|
// Sanity: row's claim-param is not in the URL before the click.
|
|
expect(window.location.href).not.toContain("claim=");
|
|
|
|
// Click it. With the fix, the click handler stops propagation before
|
|
// the row's onClick can fire. Without the fix, the row's open()
|
|
// would push a phantom ?claim=CLM-1 onto window.history.
|
|
await act(async () => {
|
|
btn!.click();
|
|
});
|
|
|
|
// The URL must not carry a ?claim= param. buildUrl() inside open()
|
|
// would have added that param if the click had bubbled to the row.
|
|
// (See the MemoryRouter note above for why we don't also assert
|
|
// on /providers here.)
|
|
expect(window.location.href).not.toContain("claim=");
|
|
|
|
unmount();
|
|
});
|
|
});
|