From cb74bc9307ecf62d3c11e9a30702cf30207158c3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 20:53:58 -0600 Subject: [PATCH] test(sp8): add coverage for resubmitRejectedWithDownload + bundle modal flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inbox-api.test.ts: pin ?download=true POST contract; blob + filename + X-Cyclone-Serialize-Errors parsing; non-2xx error surfacing. - Inbox.test.tsx: end-to-end multi-select → Resubmit + Download path verifies api call args and downloadBlob wiring. - inbox-api.ts: drop redundant isConfigured short-circuit in download variant (backend has its own auth gate) and switch error reading to res.text() to match Blob response shape. --- src/lib/inbox-api.test.ts | 55 ++++++++++++++++++++ src/lib/inbox-api.ts | 3 +- src/pages/Inbox.test.tsx | 107 +++++++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 3 deletions(-) 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); + }); });