Files
cyclone/src/pages/Claims.test.tsx
T

535 lines
18 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 { Claims } from "./Claims";
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`.
*/
function renderClaims(): { unmount: () => void } {
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(Claims)
)
);
});
return {
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();
// Header shows 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(
() =>
document.body.querySelector('[data-testid="header-id"]')?.textContent ===
"CLM-1"
);
expect(
document.body.querySelector('[data-testid="header-id"]')?.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).
await settle(
() => document.body.querySelector('[data-testid="header-close"]') !== 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="header-close"]'
) 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();
});
});