35e0f5a422
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.
525 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|