// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
import * as apiModule from "@/lib/api";
import * as downloadModule from "@/lib/download";
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
// dismiss) are wrapped in RoleGate, which reads the auth context to
// decide whether the current user has write permission. We don't want
// the tests to spin up the full AuthProvider + /api/auth/me probe —
// instead we stub useAuth to return an admin user so RoleGate renders
// the BulkBars synchronously on first render.
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(),
}),
}));
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
// claim drilldown to /claims?claim=ID), so the render harness needs
// a Router context. We use a fresh QueryClient per test so the
// drawer's per-remit query doesn't leak cache between cases, and a
// MemoryRouter so `useNavigate` has a router to push into.
//
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
// the navigation assertion uses a small LocationTracker component
// mounted under the same router that records the current pathname +
// search after each render. Tests then read `tracker.last` instead
// of `window.location`.
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;
}
function renderInbox() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
// Capture the last location seen so tests can assert on navigation
// without poking at window.location (MemoryRouter doesn't touch it).
const captured = { pathname: "/inbox", search: "" };
const view = render(
{
captured.pathname = pathname;
captured.search = search;
}}
/>
,
);
// Attach the tracker so individual tests can read it. Note: the
// captured value updates asynchronously after navigation, but since
// LocationTracker runs in the same render pass as the navigate,
// tests should `waitFor` it.
(view as unknown as { tracker: typeof captured }).tracker = captured;
return view as ReturnType & { tracker: typeof captured };
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("Inbox page", () => {
it("renders the five lane headers", async () => {
// SP14: the Payer-Rejected lane was added (277CA STC A4/A6/A7).
// The Inbox now renders 5 lanes: REJECTED, PAYER REJECTED,
// CANDIDATES, UNMATCHED, DONE.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("REJECTED");
expect(container.textContent).toContain("PAYER REJECTED");
expect(container.textContent).toContain("CANDIDATES");
expect(container.textContent).toContain("UNMATCHED");
expect(container.textContent).toContain("DONE");
});
});
it("SP14: payer-rejected row count rolls up into the need-eyes header", async () => {
// The "need eyes" total in the header is the operator's
// anchor for the day. Payer-rejected claims are actionable
// (operator must acknowledge or follow up), so they count
// toward the need-eyes total alongside 999 rejected, candidates,
// and unmatched.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [
{ id: "PR1", kind: "claim", patient_control_number: "PR1",
charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
state: "submitted", rejection_reason: "A7 invalid dx",
service_date_from: null, payer_rejected_status_code: "A7",
payer_rejected_reason: "invalid diagnosis",
payer_rejected_at: "2026-06-20T10:00:00Z" },
],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("1");
expect(container.textContent).toContain("items need eyes");
});
});
it("SP14: acknowledging selected payer-rejected claims calls the API and refetches", async () => {
// The user selects a payer-rejected claim, hits Acknowledge, and
// the inbox calls /api/inbox/payer-rejected/acknowledge with the
// claim id, then refetches the lane payload.
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/api/inbox/lanes")) {
return {
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [
{ id: "PR1", kind: "claim", patient_control_number: "PR1",
charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
state: "submitted", rejection_reason: "A7 invalid dx",
service_date_from: null, payer_rejected_status_code: "A7",
payer_rejected_reason: "invalid diagnosis",
payer_rejected_at: "2026-06-20T10:00:00Z" },
],
candidates: [],
unmatched: [],
done_today: [],
}),
};
}
if (url.includes("/api/inbox/payer-rejected/acknowledge")) {
return {
ok: true,
json: async () => ({
ok: true,
transitioned: 1,
already_acked: 0,
not_found: 0,
not_rejected: 0,
}),
};
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
const { container, getByTestId } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("PR1");
});
// Select the PR1 claim via its checkbox.
const checkbox = container.querySelector(
'input[type="checkbox"][aria-label="Select PR1"]',
) as HTMLInputElement;
expect(checkbox).toBeTruthy();
await act(async () => {
checkbox.click();
});
// Hit Acknowledge. The bulk action bar button is wired to the
// /api/inbox/payer-rejected/acknowledge endpoint.
const ackButton = getByTestId("payer-rejected-acknowledge");
await act(async () => {
ackButton.click();
});
// The mock was called with the acknowledge URL.
const ackCall = fetchMock.mock.calls.find(
(c) => typeof c[0] === "string" && c[0].includes("/api/inbox/payer-rejected/acknowledge"),
);
expect(ackCall).toBeTruthy();
expect(ackCall).toBeDefined();
const ackBody = JSON.parse(ackCall![1].body);
expect(ackBody.claim_ids).toEqual(["PR1"]);
});
it("SP8: multi-select resubmit opens bundle modal; Resubmit + Download triggers api + downloadBlob", async () => {
// Two rejected claims → user selects both → Re-submit → modal appears
// → user clicks Resubmit + Download → backend is hit with
// ?download=true and the returned blob is handed to downloadBlob.
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/api/inbox/lanes")) {
return {
ok: true,
json: async () => ({
rejected: [
{ id: "C1", kind: "claim", payer_claim_control_number: "C1",
charge_amount: 100, payer_id: "P1", provider_npi: "N1",
state: "rejected", rejection_reason: "old", service_date: null,
score: null },
{ id: "C2", kind: "claim", payer_claim_control_number: "C2",
charge_amount: 200, payer_id: "P1", provider_npi: "N1",
state: "rejected", rejection_reason: "old", service_date: null,
score: null },
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
};
}
if (url.includes("/api/inbox/rejected/resubmit")) {
return {
ok: true,
blob: async () => new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
headers: {
get: (name: string) =>
name.toLowerCase() === "content-disposition"
? 'attachment; filename="resubmit-2-claims.zip"'
: name.toLowerCase() === "x-cyclone-serialize-errors"
? null
: null,
},
};
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
// Spy on the two side-effects we care about: the api call + the
// browser download. Both are module-scope so we install spies and
// reset between tests (afterEach already calls vi.restoreAllMocks
// but we mockReset to clear call history from other tests).
const resubmitDownloadSpy = vi
.spyOn(inboxApi, "resubmitRejectedWithDownload")
.mockResolvedValue({
blob: new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
filename: "resubmit-2-claims.zip",
serializeErrors: [],
});
const downloadSpy = vi
.spyOn(downloadModule, "downloadBlob")
.mockImplementation(() => {});
const { container, getByText } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("C1");
expect(container.textContent).toContain("C2");
});
// Select both rejected rows (the checkbox is the only per row).
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
expect(checkboxes.length).toBeGreaterThanOrEqual(2);
await act(async () => {
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
});
// BulkBar shows a Re-submit button for the rejected lane.
const resubmitBtn = getByText(/Re-submit \(2\)/);
await act(async () => {
fireEvent.click(resubmitBtn);
});
// Modal appears with both options.
const modal = container.querySelector(
'[data-testid="resubmit-bundle-modal"]',
);
expect(modal).not.toBeNull();
const downloadBtn = container.querySelector(
'[data-testid="resubmit-modal-download"]',
) as HTMLButtonElement | null;
expect(downloadBtn).not.toBeNull();
await act(async () => {
fireEvent.click(downloadBtn!);
});
// The api was called with the selected ids, and the blob was
// handed to downloadBlob with the right filename.
expect(resubmitDownloadSpy).toHaveBeenCalledTimes(1);
expect(resubmitDownloadSpy).toHaveBeenCalledWith(["C1", "C2"]);
expect(downloadSpy).toHaveBeenCalledTimes(1);
const [filename, blob] = downloadSpy.mock.calls[0];
expect(filename).toBe("resubmit-2-claims.zip");
expect(blob).toBeInstanceOf(Blob);
});
it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => {
// Candidates are remits (payer_claim_control_number-keyed) — the
// row's `id` is the remit id, so clicking drills into the
// RemitDrawer via `?remit=ID`.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [
{
id: "REM-7",
kind: "remit",
payer_claim_control_number: "REM-7",
charge_amount: 200,
payer_id: "P1",
rendering_provider_npi: "1234567890",
service_date: "2026-06-19",
candidates: [],
},
],
unmatched: [],
done_today: [],
}),
}),
);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("REM-7");
});
// No drawer yet — the URL is `/inbox` with no `?remit=`.
expect(
container.querySelector('[data-testid="remit-drawer"]')
).toBeNull();
// Click the candidate row. The InboxRow renders as a with
// the remit id in its first cell; clicking that row bubbles to
// the Lane's onRowClick handler we wired in Task 4.4.
const cell = Array.from(container.querySelectorAll("td")).find(
(td) => td.textContent === "REM-7",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
// Drawer portals into document.body — check there, not container.
await waitFor(() => {
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
});
});
it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => {
// Pre-set the URL so the hook reads REM-7 off `window.location.search`
// during its `useState` initializer — no click needed. The inbox
// already mounts the drawer, so a deep link to /inbox?remit=REM-7
// should land with the drawer open.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/inbox?remit=REM-7",
);
renderInbox();
await waitFor(() => {
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
});
});
it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
// Task 5.4 wires the rejected lane's onRowClick to navigate to
// the ClaimDrawer via `?claim=ID` on the /claims route. The
// MemoryRouter (initial /inbox) lets us observe the URL change
// via the LocationTracker helper mounted in the render harness.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
// Click the rejected row. The InboxRow renders the claim id as
// its primary text cell; clicking that row bubbles up to the
// Lane's onRowClick handler we wired in Task 5.4.
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "REJ1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
// MemoryRouter navigates to /claims?claim=REJ1 — verify the
// tracker observed the new pathname + search.
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=REJ1");
});
});
it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
// done_today rows are claim-shaped — same drill pattern as the
// rejected lane. Verifies that the wiring covers the trailing
// "shipped today" lane too, not just the error lanes.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [
{
id: "DONE1",
kind: "claim",
patient_control_number: "DONE1",
charge_amount: 88,
payer_id: "P1",
provider_npi: "1234567890",
state: "submitted",
service_date_from: "2026-06-21",
},
],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("DONE1");
});
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "DONE1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=DONE1");
});
});
it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
// Per the plan's §self-review #5, the Lane's RowCheckbox already
// calls e.stopPropagation() — verify that's still the case by
// clicking the checkbox and confirming the URL didn't change to
// /claims. (If stopPropagation regressed, the row click handler
// would fire and navigate.)
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
const checkbox = view.container.querySelector(
'input[type="checkbox"][aria-label="Select REJ1"]',
) as HTMLInputElement;
expect(checkbox).toBeTruthy();
await act(async () => {
checkbox.click();
});
// URL should still be /inbox — clicking the checkbox selected
// the row but did not drill into the claim drawer.
expect(view.tracker.pathname).toBe("/inbox");
});
// -------------------------------------------------------------------------
// SP28: the new "ACK ORPHANS" lane (D7). Mirrors the payer-rejected
// lane shape; per-row "Dismiss" + "Link to…" actions; empty state
// when no orphans; live-tail integration drops a row that just got
// linked via the manual-match endpoint.
// -------------------------------------------------------------------------
/**
* URL-aware fetch mock for the inbox page. Routes `/api/inbox/lanes`
* to the supplied `lanes`, and `/api/inbox/ack-orphans?kind=` to
* the matching subset of `orphans`. The hook fires three parallel
* requests (one per kind: 999 / 277ca / ta1), so the mock must
* filter by `kind` to avoid returning the same payload three times.
* Default orphan response is empty so tests that don't care about
* the new lane just get a clean "No orphan acks." placeholder.
*/
function makeInboxFetch(
lanes: Record,
orphans: Array<{ kind: string } & Record> = [],
) {
return vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/api/inbox/ack-orphans")) {
// Pull the `kind` query param out of the URL so the mock
// returns only the orphans for the requested kind.
const m = /[?&]kind=([^&]+)/.exec(url);
const requestedKind = m?.[1] ?? "";
const filtered = orphans.filter((o) => o.kind === requestedKind);
return {
ok: true,
json: async () => ({ items: filtered, total: filtered.length }),
};
}
return { ok: true, json: async () => lanes };
});
}
it("test_ack_orphans_lane_renders_empty_state_when_no_orphans", async () => {
vi.stubGlobal("fetch", makeInboxFetch({
rejected: [], payer_rejected: [], candidates: [], unmatched: [], done_today: [],
}));
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("ACK ORPHANS");
});
// Empty-state copy must surface — the lane's friendly "nothing
// here" treatment rather than a quiet section.
expect(view.container.textContent).toContain("No orphan acks.");
// No rows rendered.
expect(
view.container.querySelectorAll('[data-testid="ack-orphan-row"]').length
).toBe(0);
});
it("test_ack_orphans_lane_renders_one_row_per_orphan", async () => {
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
[
{
id: 100,
kind: "999",
pcn: "991102989",
sourceBatchId: "b-orphan-1",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [{ claimId: "CLM-1", score: 0.92, tier: "strong" }],
},
{
id: 200,
kind: "ta1",
pcn: "000000001",
sourceBatchId: "b-orphan-2",
parsedAt: "2026-07-01T11:00:00Z",
candidates: [],
},
],
),
);
const view = renderInbox();
await waitFor(() => {
// Wait until both rows are in the DOM.
expect(
view.container.querySelectorAll('[data-testid="ack-orphan-row"]')
.length,
).toBe(2);
});
// PCN is rendered verbatim — the operator's primary identifier.
expect(view.container.textContent).toContain("991102989");
expect(view.container.textContent).toContain("000000001");
// The kind badge is visible per row.
const badges = view.container.querySelectorAll(
'[data-testid="ack-orphan-kind-badge"]',
);
expect(badges.length).toBe(2);
// The header count is 2 (matches the row count).
expect(
view.container.querySelector('[data-testid="ack-orphans-count"]')
?.textContent,
).toBe("2");
});
it("test_ack_orphans_lane_dismiss_button_drops_row_optimistically", async () => {
const orphans = [
{
id: 300,
kind: "277ca",
pcn: "X-1",
sourceBatchId: "b-orphan-3",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [],
},
];
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
orphans,
),
);
const view = renderInbox();
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-row"]'),
).not.toBeNull();
});
// Click the dismiss button — the row drops optimistically.
const dismissBtn = view.container.querySelector(
'[data-testid="ack-orphan-dismiss"]',
) as HTMLButtonElement | null;
expect(dismissBtn).not.toBeNull();
await act(async () => {
dismissBtn!.click();
});
// Empty-state copy surfaces now.
await waitFor(() => {
expect(view.container.textContent).toContain("No orphan acks.");
});
});
it("test_ack_orphans_lane_link_to_dropdown_lists_candidates", async () => {
const orphans = [
{
id: 400,
kind: "999",
pcn: "Y-1",
sourceBatchId: "b-orphan-4",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [
{ claimId: "CLM-A", score: 0.95, tier: "strong" },
{ claimId: "CLM-B", score: 0.62, tier: "weak" },
],
},
];
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
orphans,
),
);
const view = renderInbox();
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-row"]'),
).not.toBeNull();
});
// Open the candidate list.
const toggle = view.container.querySelector(
'[data-testid="ack-orphan-link-toggle"]',
) as HTMLButtonElement | null;
expect(toggle).not.toBeNull();
await act(async () => {
toggle!.click();
});
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-candidate-list"]'),
).not.toBeNull();
});
// Two candidate options render — one per backend-supplied match.
const options = view.container.querySelectorAll(
'[data-testid="ack-orphan-candidate-option"]',
);
expect(options.length).toBe(2);
expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
});
// -------------------------------------------------------------------------
// SP29: per-row Resubmit button on rejected-lane claim rows downloads
// the single-claim corrected 837. The bulk resubmit path (multi-select)
// is exercised above in the "SP8 multi-select resubmit" test; this
// covers the dedicated single-claim gesture and the operator's
// most-click flow ("I see one reject in the lane, I click Resubmit
// next to it").
// -------------------------------------------------------------------------
it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
// Lane returns one rejected claim. The page is responsible for
// calling api.serializeClaim837(id) and handing the returned
// text to downloadTextFile as .x12.
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/api/inbox/lanes")) {
return {
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
patient_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 AK9 R",
rejected_at: "2026-07-02T09:00:00Z",
service_date_from: null,
claim_acks: {
total: 2,
rejected: 1,
items: [
{
ack_id: 50,
set_control_number: "991102989",
set_accept_reject_code: "R",
ak2_index: 0,
linked_at: "2026-07-02T09:00:00Z",
},
{
ack_id: 51,
set_control_number: "991102989",
set_accept_reject_code: "A",
ak2_index: 1,
linked_at: "2026-07-02T08:59:00Z",
},
],
},
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
};
}
return { ok: true, json: async () => ({}) };
}),
);
// Spy on the api call AND the download sink. The page calls
// `api.serializeClaim837(id)` (namespace import from @/lib/api),
// and downloadTextFile is on the download module.
const serializeSpy = vi
.spyOn(apiModule.api, "serializeClaim837")
.mockResolvedValue({
text: "ISA*00*~...",
filename: "claim-REJ1.x12",
});
const downloadSpy = vi
.spyOn(downloadModule, "downloadTextFile")
.mockImplementation(() => {});
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
// Click the per-row Resubmit button — its testid includes the
// claim id so the operator can target one row at a time.
const btn = view.container.querySelector(
'[data-testid="resubmit-REJ1"]',
) as HTMLButtonElement | null;
expect(btn).not.toBeNull();
expect(btn!.textContent).toBe("Resubmit");
await act(async () => {
fireEvent.click(btn!);
});
// The api was called with the row's claim id, and the resulting
// .x12 was handed to downloadTextFile with text/x12 mime so the
// browser saves it as a download.
await waitFor(() => {
expect(serializeSpy).toHaveBeenCalledTimes(1);
expect(serializeSpy).toHaveBeenCalledWith("REJ1");
});
expect(downloadSpy).toHaveBeenCalledTimes(1);
const [filename, mime, text] = downloadSpy.mock.calls[0];
expect(filename).toBe("claim-REJ1.x12");
expect(mime).toBe("text/x12");
expect(text).toBe("ISA*00*~...");
});
});