// @vitest-environment happy-dom // Tell React this is an `act`-aware test environment so react-query's // internal state updates flush through without noisy console warnings. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { MemoryRouter, useLocation } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReconciliationPage } from "./Reconciliation"; import { api } from "@/lib/api"; // "Match selected" is wrapped in RoleGate, which reads the auth // context. Stub useAuth to return an admin user so the gate renders // the button synchronously on first render — no AuthProvider + /me // probe needed in the test harness. vi.mock("@/auth/useAuth", () => ({ useAuth: () => ({ status: "authenticated" as const, user: { id: "test-admin", username: "test-admin", role: "admin" as const, created_at: "2026-01-01T00:00:00Z", }, login: vi.fn(), logout: vi.fn(), refresh: vi.fn(), }), })); // Module-level mock: vitest hoists `vi.mock` calls above imports. Only the // methods this page touches need to be stubbed; ApiError is re-exported so // the page can branch on .status in its catch handler. vi.mock("@/lib/api", () => ({ api: { listUnmatched: vi.fn(), matchRemit: vi.fn(), unmatchClaim: vi.fn(), getRemittance: vi.fn(), }, ApiError: class ApiError extends Error { constructor(public status: number, message: string) { super(message); } }, })); /** * Tracks the current MemoryRouter location so tests can assert on * navigation without poking at window.location (MemoryRouter doesn't * touch it). Mounted as a sibling under the same router. */ function LocationTracker({ on, }: { on: (pathname: string, search: string) => void; }) { const loc = useLocation(); useEffect(() => { on(loc.pathname, loc.search); }, [loc.pathname, loc.search, on]); return null; } /** * Minimal `render` helper using react-dom/client + act(). Mirrors the * `renderHook` helper in `useReconciliation.test.ts` — see that file's * comment for the rationale (project doesn't ship a DOM test library * yet, and adding one just for these tests would inflate the dev-deps * tree). Returns the rendered container so tests can assert against * the live DOM via `container.textContent`. * * SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the * `useNavigate()` call (claim drill to /claims?claim=ID) has a router * context. Without this, `useNavigate()` would throw in tests that * trigger a claim-card click. Tests that don't need a router can keep * using the default (no router) path. */ function renderIntoContainer( element: React.ReactElement, options: { withRouter?: boolean; initialEntries?: string[] } = {}, ): { container: HTMLDivElement; unmount: () => void; tracker?: { pathname: string; search: string }; } { const container = document.createElement("div"); document.body.appendChild(container); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); let tracker: { pathname: string; search: string } | undefined; const track = (pathname: string, search: string) => { if (!tracker) tracker = { pathname, search }; tracker.pathname = pathname; tracker.search = search; }; const inner = options.withRouter ? React.createElement( MemoryRouter, { initialEntries: options.initialEntries ?? ["/reconciliation"], }, React.createElement(LocationTracker, { on: track }), element, ) : element; const root: Root = createRoot(container); act(() => { root.render( React.createElement( QueryClientProvider, { client: qc }, inner ) ); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, tracker, }; } /** * Flush microtasks + react state updates until the predicate holds or we * time out. react-query's fetch resolves asynchronously, so we have to * await a tick before the rendered DOM reflects the mocked data. */ async function waitForText( text: string, timeoutMs = 2000 ): Promise { const start = Date.now(); while (!document.body.textContent?.includes(text)) { if (Date.now() - start > timeoutMs) { throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`); } await act(async () => { await Promise.resolve(); }); } } describe("ReconciliationPage", () => { beforeEach(() => { vi.clearAllMocks(); // Default for the per-remit detail fetch — the drawer fetches // this whenever `?remit=` is in the URL. Return a never-resolving // promise so the drawer stays in the loading state; the smoke // test only asserts the drawer mounts. ( api.getRemittance as unknown as ReturnType ).mockReturnValue(new Promise(() => {})); // SP21 Phase 5 Task 5.5: reset window.location to a clean // /reconciliation URL so `useRemitDrawerUrlState`'s mount-time // read doesn't see a `?remit=REM-DRILL` from the previous // test's `open()` history push. Without this, a test that // asserts the drawer isn't open still finds the drawer // mounted on initial render (driven by history state, not by // a click). (window as unknown as { happyDOM: { setURL: (u: string) => void } }) .happyDOM.setURL("http://localhost/reconciliation"); // SP21 Phase 5 Task 5.5: the previous test may have left a Radix // portal in document.body. Clear them before each test so a // "drawer not in DOM" assertion isn't polluted by a stale portal. document.body .querySelectorAll('[role="dialog"]') .forEach((node) => node.remove()); }); afterEach(() => { // SP21 Phase 5 Task 5.5: the RemitDrawer portals into // document.body. Each test renders + unmounts, but happy-dom's // document.body persists across tests within the file, so we // explicitly remove any lingering Radix portals here. Without // this, a test that asserts `drawer not in DOM` would still // find a leftover portal from the previous test. document.body .querySelectorAll('[role="dialog"]') .forEach((node) => node.remove()); }); it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => { // Non-empty unmatched payload so the page renders the two-column // matching surface (the "Pair them." branch). The pre-existing // empty-state branch has a flaky happy-dom/race that's unrelated // to Phase 4 — using non-empty data sidesteps that bug and still // proves the deep-link → drawer mount works. We pre-set // `window.location` (which `useRemitDrawerUrlState` reads on // mount) so `?remit=REM-7` resolves to a truthy `remitId`. ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [ { id: "CLM-1", patientName: "Patient A", billedAmount: 100, providerNpi: "1234567890", serviceDate: "2026-06-01", payerId: "P1", state: "submitted", }, ], remittances: [ { id: "REM-7", payerClaimControlNumber: "PCN-A", status: "received", paidAmount: 100, adjustmentAmount: 0, receivedDate: "2026-06-01", isReversal: false, totalCharge: 100, serviceDate: "2026-06-01", batchId: "b1", }, ], }); (window as unknown as { happyDOM: { setURL: (u: string) => void } }) .happyDOM.setURL("http://localhost/reconciliation?remit=REM-7"); const { unmount } = renderIntoContainer( React.createElement(ReconciliationPage), { withRouter: true }, ); // Wait for the loaded two-column view (the "Pair them." headline // is the clearest signal that listUnmatched has resolved and the // page is past the loading + empty branches), then assert the // drawer is mounted. await waitForText("Pair them."); expect( document.body.querySelector('[data-testid="remit-drawer"]'), ).not.toBeNull(); unmount(); // Reset URL so the next test sees a clean /reconciliation URL. (window as unknown as { happyDOM: { setURL: (u: string) => void } }) .happyDOM.setURL("http://localhost/reconciliation"); }); it("renders both columns with unmatched rows when api returns data", async () => { ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [ { id: "CLM-1", patientName: "Patient A", billedAmount: 100, providerNpi: "1234567890", serviceDate: "2026-06-01", payerId: "P1", state: "submitted", }, ], remittances: [ { id: "CLP-1:b1", payerClaimControlNumber: "PCN-A", status: "received", paidAmount: 100, adjustmentAmount: 0, receivedDate: "2026-06-01", isReversal: false, totalCharge: 100, serviceDate: "2026-06-01", batchId: "b1", }, ], }); const { unmount } = renderIntoContainer( React.createElement(ReconciliationPage), { withRouter: true }, ); await waitForText("CLM-1"); // Both column headings + both rows should be present. expect(document.body.textContent).toContain("CLM-1"); expect(document.body.textContent).toContain("PCN-A"); expect(document.body.textContent).toContain("Unmatched claims"); expect(document.body.textContent).toContain("Unmatched remits"); unmount(); }); it("renders the empty state when nothing is unmatched", async () => { ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [], remittances: [] }); const { unmount } = renderIntoContainer( React.createElement(ReconciliationPage), { withRouter: true }, ); await waitForText("nothing pending"); expect(document.body.textContent).toContain("Reconciliation · nothing pending"); expect(document.body.textContent).toContain("Every claim and remittance is paired."); // No two-column selection chrome should render. expect(document.body.textContent).not.toContain("Unmatched claims ("); unmount(); }); it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => { // Task 5.5 splits the gesture: clicking the card body drills into // the ClaimDrawer via /claims?claim=ID, while a separate // "Select for match" button toggles the row's selection. The // card is no longer a single