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

665 lines
20 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.
// Mirrors the convention used by Batches.test.tsx and Acks.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 { MemoryRouter } from "react-router-dom";
import {
describe,
expect,
it,
vi,
beforeEach,
afterEach,
} from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BatchDiff } from "./BatchDiff";
import { api, ApiError } from "@/lib/api";
import type { BatchDiff as BatchDiffResponse } from "@/types";
// Mock the api module. Only the surface the page actually touches is
// stubbed; everything else is left undefined so an accidental call
// blows up loudly.
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listBatches: vi.fn(),
getBatchDiff: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
},
}));
/**
* Mount the page into a container wrapped in MemoryRouter +
* QueryClientProvider. Uses a 10ms retryDelay so non-404 errors don't
* blow past the test timeout — same pattern used by Batches.test.tsx.
*/
function renderIntoContainer(
element: React.ReactElement,
initialEntries: string[] = ["/batch-diff"],
): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({
defaultOptions: {
queries: {
retry: false,
retryDelay: 10,
},
},
});
const root: Root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
</MemoryRouter>,
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/**
* Flush microtasks + react state updates until the predicate holds or
* the deadline expires. react-query's fetch resolves asynchronously
* so we have to await a tick before the rendered DOM reflects the
* mocked data.
*/
async function waitFor(
predicate: () => boolean,
description: string,
timeoutMs = 2000,
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(`waitFor: ${description} did not become true within ${timeoutMs}ms`);
}
await act(async () => {
await Promise.resolve();
});
}
}
// Fixtures used by the data-driven tests.
const BATCH_A = {
id: "B-A",
kind: "837p" as const,
inputFilename: "original.837",
parsedAt: "2026-06-15T12:00:00Z",
claimCount: 2,
};
const BATCH_B = {
id: "B-B",
kind: "837p" as const,
inputFilename: "corrected.837",
parsedAt: "2026-06-16T12:00:00Z",
claimCount: 3,
};
function makeDiffPayload(): BatchDiffResponse {
return {
a: {
id: BATCH_A.id,
kind: "837p",
parsedAt: BATCH_A.parsedAt,
inputFilename: BATCH_A.inputFilename,
claimCount: 2,
},
b: {
id: BATCH_B.id,
kind: "837p",
parsedAt: BATCH_B.parsedAt,
inputFilename: BATCH_B.inputFilename,
claimCount: 3,
},
added: [
{
claim_id: "CLM-3",
patient_name: "Sam Roe",
total_charge: 200,
service_date: "2026-06-02",
status: "submitted",
cpt_codes: ["99213"],
},
],
removed: [
{
claim_id: "CLM-2",
patient_name: "Jane Doe",
total_charge: 75,
service_date: "2026-06-01",
status: "submitted",
cpt_codes: ["99213"],
},
],
changed: [
{
a: {
claim_id: "CLM-1",
patient_name: "Pat Lee",
total_charge: 100,
service_date: "2026-06-01",
status: "submitted",
cpt_codes: ["99213"],
},
b: {
claim_id: "CLM-1",
patient_name: "Pat Lee",
total_charge: 150,
service_date: "2026-06-01",
status: "submitted",
cpt_codes: ["99213"],
},
diff: [{ field: "total_charge", a: 100, b: 150 }],
},
],
summary: {
addedCount: 1,
removedCount: 1,
changedCount: 1,
unchangedCount: 1,
},
};
}
describe("BatchDiff page", () => {
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, not the loaded data.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
afterEach(() => {
// happy-dom persists window.location between tests; reset so the
// URL-state hook starts from a clean slate.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/batch-diff");
});
it("SP21 Task 4.5: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
// Pre-set the URL with `?remit=`. The page doesn't surface any
// per-remit rows (the diff payload is claim-level), but the
// drawer must still mount so the URL contract is honored.
//
// `useRemitDrawerUrlState` reads `window.location.search`
// (NOT React Router's search params) so we have to set the
// global window URL via happyDOM AND give MemoryRouter an
// initial entry — both stay in lockstep.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/batch-diff?remit=REM-7");
(
api.listBatches as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue([BATCH_A, BATCH_B]);
const { unmount } = renderIntoContainer(
<BatchDiff />,
["/batch-diff?remit=REM-7"],
);
// Page header must be present + drawer must be open.
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
"page header mounted",
);
await waitFor(
() => !!document.querySelector('[data-testid="remit-drawer"]'),
"remit drawer mounted via deep link",
);
expect(
document.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("renders the awaiting-picks empty state when no batches are selected", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
const { unmount } = renderIntoContainer(<BatchDiff />);
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
"page header mounted",
);
// The copy unique to the empty state — verifier the page rendered
// the awaiting-picks branch and not the loading skeleton.
expect(document.body.textContent).toContain("awaiting picks");
expect(document.querySelector('[data-testid="diff-picker-a"]')).not.toBeNull();
expect(document.querySelector('[data-testid="diff-picker-b"]')).not.toBeNull();
// Diff not requested yet — getBatchDiff should NOT have been
// called because the hook gates on both picks being non-null.
expect(api.getBatchDiff).not.toHaveBeenCalled();
unmount();
});
it("renders the loading skeleton while the diff is in flight", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
// Never resolves — keeps the query in the loading state.
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise(() => {}),
);
// Start the page with both picks already in the URL.
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-skeleton"]'),
"loading skeleton visible",
);
unmount();
});
it("renders the three-section diff view when the backend returns deltas", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
// Wait for the diff view (full, non-empty payload).
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-view"]'),
"diff view rendered",
);
// Sections are present.
expect(
document.querySelector('[data-testid="batch-diff-added-section"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="batch-diff-removed-section"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="batch-diff-changed-section"]'),
).not.toBeNull();
// Per-claim rows render with the right indicator + id.
expect(
document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
).not.toBeNull();
// Indicator chips are present for each bucket.
expect(
document.querySelector('[data-testid="diff-indicator-added"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="diff-indicator-removed"]'),
).not.toBeNull();
expect(
document.querySelector('[data-testid="diff-indicator-changed"]'),
).not.toBeNull();
unmount();
});
it("renders the no-deltas state when the backend reports an empty diff", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
const empty = makeDiffPayload();
empty.added = [];
empty.removed = [];
empty.changed = [];
empty.summary = {
addedCount: 0,
removedCount: 0,
changedCount: 0,
unchangedCount: 2,
};
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
empty,
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-empty"]'),
"empty-diff state visible",
);
// Summary cards still surface the counts even when zero.
expect(
document.querySelector('[data-testid="batch-diff-summary"]'),
).not.toBeNull();
unmount();
});
it("renders the not-found state on a 404 from getBatchDiff", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
// Mirror the backend's 404 contract: ApiError(404).
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new ApiError(404, "Not found — batch B-A not found"),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
// The not-found state uses the ErrorState primitive with a retry
// affordance; assert by message text since we don't ship a
// dedicated testid for the variant.
await waitFor(
() => (document.body.textContent ?? "").includes("not found"),
"not-found error text visible",
);
expect(
(document.body.textContent ?? "").toLowerCase(),
).toContain("one of these batches was not found");
unmount();
});
it("renders the network error state on a non-404 failure", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("500 Internal Server Error — upstream"),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() =>
(document.body.textContent ?? "").includes(
"Couldn't compute the diff",
),
"network error text visible",
);
// Retry button is rendered (the ErrorState primitive's affordance).
expect(
(document.body.textContent ?? "").toLowerCase(),
).toContain("retry");
unmount();
});
it("passes the URL a/b picks through to getBatchDiff", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0,
"getBatchDiff invoked",
);
// The two picks are passed through in order: a, b.
expect(api.getBatchDiff).toHaveBeenCalledWith(BATCH_A.id, BATCH_B.id);
unmount();
});
it("Clear picks button resets both pickers and returns to awaiting-picks state", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const { unmount } = renderIntoContainer(
<BatchDiff />,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-clear-button"]'),
"clear button visible",
);
const clear = document.querySelector(
'[data-testid="diff-clear-button"]',
) as HTMLButtonElement | null;
await act(async () => {
clear?.click();
await Promise.resolve();
});
// Back to the awaiting-picks empty state. The hook's
// `enabled: a && b` predicate stops getBatchDiff from being
// re-invoked once both ids are null.
await waitFor(
() => (document.body.textContent ?? "").includes("awaiting picks"),
"empty state re-appears",
);
// The picker for B should now be empty (the trigger renders the
// placeholder text).
expect(
(document.body.textContent ?? "").toLowerCase(),
).toContain("pick a batch");
unmount();
});
it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
// Each ClaimIdCell wraps its id text with DrillableCell, whose
// onClick navigates to /claims?claim=ID. The MemoryRouter gives
// us a router context so the navigation completes; we observe
// it via the rendered pathname on a LocationTracker spy.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
"added row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
);
expect(cell).not.toBeNull();
// DrillableCell wraps the id text in its own <button>; click the
// nearest button ancestor so the drillable onClick fires.
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
"navigation to /claims?claim=CLM-3",
);
unmount();
});
it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
// The diff can list claim ids that no longer exist in the DB
// (Removed from A means "was in A, not in B" — the claim may
// still exist or may have been purged). Either way, clicking
// the id drills to /claims?claim=ID; the ClaimDrawer's 404
// state (Phase 2) handles the missing-claim case. This test
// only verifies the click path, not the drawer's 404 surface.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
"removed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
"navigation to /claims?claim=CLM-2",
);
unmount();
});
it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
// The Changed row uses the A-side id (per existing test
// assertions). Drill should fire on that id.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
"changed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
"navigation to /claims?claim=CLM-1",
);
unmount();
});
});
// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
// by the LocationTracker test helper below. Aliased to keep the import
// block tidy alongside React + the page import.
import { useLocation as useLocationSafe } from "react-router-dom";
// Keep `ApiError` referenced so the import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;