Files
cyclone/src/pages/Reconciliation.test.tsx
T
Nora 35e0f5a422 feat(auth): disable write affordances for viewer role
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:

  - Upload.tsx: dropzone + Parse/Clear buttons
  - Inbox.tsx: rejected / payer_rejected / candidates BulkBars
  - Reconciliation.tsx: 'Match selected' button

Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
2026-06-22 15:54:38 -06:00

525 lines
17 KiB
TypeScript

// @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<void> {
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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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 <button>; the body region is a
// button with its own onClick that calls navigate().
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-DRILL",
patientName: "Patient Drill",
billedAmount: 250,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true, initialEntries: ["/reconciliation"] },
);
await waitForText("CLM-DRILL");
// Click the claim card body (the body region is the inner <button>
// that contains the id). It's not the "Select for match" toggle
// — the body region has aria-label="View claim CLM-DRILL in detail".
const bodyBtn = document.body.querySelector(
'button[aria-label="View claim CLM-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
expect(tracker?.pathname).toBe("/claims");
expect(tracker?.search).toBe("?claim=CLM-DRILL");
unmount();
});
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
// The select button calls e.stopPropagation() before setSelectedClaim,
// so the row body click handler doesn't fire. Verify the URL stays
// on /reconciliation and the "Match selected" button becomes enabled
// (or at least that selection state advances).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-SEL",
patientName: "Patient Select",
billedAmount: 175,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL",
status: "received",
paidAmount: 175,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 175,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("CLM-SEL");
// Click the claim "Select for match" toggle. It has
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
const selectBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// URL stays on /reconciliation — selection didn't drill.
expect(tracker?.pathname).toBe("/reconciliation");
// The toggle button now reads "Unselect" (clicked once).
const updatedBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(updatedBtn?.textContent).toContain("Unselect");
unmount();
});
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
// Task 5.5 also restructures the remits column — the body is no
// longer role="button" with a select-onClick handler. The body
// drills into the RemitDrawer via open(r.id); selection moves to
// a dedicated "Select for match" toggle.
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-DRILL",
payerClaimControlNumber: "PCN-DRILL",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-DRILL");
// Click the remit card body (inner <button> with aria-label).
const bodyBtn = document.body.querySelector(
'button[aria-label="View remittance PCN-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
// RemitDrawer should be mounted in document.body.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
// The remit select toggle has e.stopPropagation() — clicking it
// shouldn't open the RemitDrawer (which used to be a side effect
// when the outer card was role="button" with onClick=select).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL2",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-SEL2");
const selectBtn = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// No drawer.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).toBeNull();
// Toggle flipped.
const updated = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(updated?.textContent).toContain("Unselect");
unmount();
});
});