feat(frontend): /reconciliation page with two-column matching UI
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// @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 } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReconciliationPage } from "./Reconciliation";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// 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(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
element
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
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",
|
||||
patientControlNumber: "PCN-A",
|
||||
serviceDate: "2026-06-01",
|
||||
chargeAmount: 100,
|
||||
providerNpi: "1234567890",
|
||||
payerId: "P1",
|
||||
state: "submitted",
|
||||
},
|
||||
],
|
||||
remittances: [
|
||||
{
|
||||
id: "CLP-1:b1",
|
||||
payerClaimControlNumber: "PCN-A",
|
||||
statusCode: "1",
|
||||
totalCharge: 100,
|
||||
totalPaid: 100,
|
||||
adjustmentAmount: 0,
|
||||
serviceDate: "2026-06-01",
|
||||
isReversal: false,
|
||||
batchId: "b1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage)
|
||||
);
|
||||
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)
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user