// @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*~..."); }); });