diff --git a/src/lib/inbox-api.test.ts b/src/lib/inbox-api.test.ts
index b2c8547..bc4a12e 100644
--- a/src/lib/inbox-api.test.ts
+++ b/src/lib/inbox-api.test.ts
@@ -4,6 +4,7 @@ import {
matchCandidate,
dismissCandidates,
resubmitRejected,
+ resubmitRejectedWithDownload,
exportInboxCsvUrl,
} from "./inbox-api";
@@ -68,4 +69,58 @@ describe("inbox-api", () => {
it("exportInboxCsvUrl returns the export endpoint with the lane", () => {
expect(exportInboxCsvUrl("rejected")).toBe(`${BASE}/api/inbox/export.csv?lane=rejected`);
});
+
+ it("resubmitRejectedWithDownload posts to ?download=true and returns blob + filename", async () => {
+ // The download path returns a Blob (zip) plus the suggested filename
+ // from Content-Disposition, and surfaces per-claim serialize errors
+ // via X-Cyclone-Serialize-Errors (JSON-encoded array).
+ const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // PK header
+ const fetchSpy = vi.fn().mockResolvedValue({
+ ok: true,
+ blob: async () => new Blob([zipBytes], { type: "application/zip" }),
+ headers: {
+ get: (name: string) => {
+ const lower = name.toLowerCase();
+ if (lower === "content-disposition") {
+ return 'attachment; filename="resubmit-3-claims.zip"';
+ }
+ if (lower === "x-cyclone-serialize-errors") {
+ return JSON.stringify([{ claim_id: "C2", reason: "no raw_json" }]);
+ }
+ return null;
+ },
+ },
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const out = await resubmitRejectedWithDownload(["C1", "C2", "C3"]);
+ expect(fetchSpy).toHaveBeenCalledWith(
+ `${BASE}/api/inbox/rejected/resubmit?download=true`,
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ claim_ids: ["C1", "C2", "C3"] }),
+ }),
+ );
+ expect(out.filename).toBe("resubmit-3-claims.zip");
+ expect(out.blob).toBeInstanceOf(Blob);
+ expect(out.serializeErrors).toEqual([
+ { claim_id: "C2", reason: "no raw_json" },
+ ]);
+
+ vi.unstubAllGlobals();
+ });
+
+ it("resubmitRejectedWithDownload surfaces non-2xx as an Error", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ statusText: "Bad Request",
+ text: async () => "claim_ids required",
+ headers: { get: () => null },
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ await expect(resubmitRejectedWithDownload([])).rejects.toThrow(/400/);
+ vi.unstubAllGlobals();
+ });
});
diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts
index da4770a..1b33d09 100644
--- a/src/lib/inbox-api.ts
+++ b/src/lib/inbox-api.ts
@@ -142,7 +142,6 @@ export async function resubmitRejected(
export async function resubmitRejectedWithDownload(
claimIds: string[],
): Promise<{ blob: Blob; filename: string; serializeErrors: Array<{ claim_id: string; reason: string }> }> {
- if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl("/api/inbox/rejected/resubmit?download=true"),
{
@@ -152,7 +151,7 @@ export async function resubmitRejectedWithDownload(
},
);
if (!res.ok) {
- const detail = await readErrorBody(res);
+ const detail = await res.text().catch(() => "");
throw new Error(
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`,
);
diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx
index 07549d7..4bcdc31 100644
--- a/src/pages/Inbox.test.tsx
+++ b/src/pages/Inbox.test.tsx
@@ -1,11 +1,14 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, render, waitFor } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import Inbox from "./Inbox";
+import * as inboxApi from "@/lib/inbox-api";
+import * as downloadModule from "@/lib/download";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
+ vi.restoreAllMocks();
});
describe("Inbox page", () => {
@@ -30,4 +33,106 @@ describe("Inbox page", () => {
expect(container.textContent).toContain("DONE");
});
});
+
+ 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 },
+ ],
+ 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 } = render();
+ 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);
+ });
});