// @vitest-environment happy-dom (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act } from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { createRoot, type Root } from "react-dom/client"; // Mock the api + download + toast surfaces so the hook can be exercised // in isolation. Each test sets up its own expectations on these. vi.mock("@/lib/api", () => ({ api: { exportBatch837: vi.fn(), }, })); vi.mock("@/lib/download", () => ({ downloadBlob: vi.fn(), })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn(), }, })); import { useBatchExport, type BatchExportItem } from "./useBatchExport"; import { api } from "@/lib/api"; import { downloadBlob } from "@/lib/download"; import { toast } from "sonner"; const mockExportBatch837 = vi.mocked(api.exportBatch837); const mockDownloadBlob = vi.mocked(downloadBlob); const mockToastSuccess = vi.mocked(toast.success); const mockToastWarning = vi.mocked(toast.warning); const mockToastError = vi.mocked(toast.error); function makeItem(claimId: string): BatchExportItem { return { kind: "837p", data: { claim_id: claimId } }; } function make835Item(id: string): BatchExportItem { return { kind: "835", data: { payer_claim_control_number: id } }; } /** * Render the hook with a fixed initial list and capture its return * value. The `rerenderWith` helper re-creates the harness with a new * initial list — that's how we simulate "new claims streamed in" * without poking at internal useState setters from outside the * component. */ function setup(initial: BatchExportItem[]) { let latest: ReturnType | null = null; const container = document.createElement("div"); document.body.append(container); function Harness({ items }: { items: BatchExportItem[] }) { latest = useBatchExport(items); return null; } const root: Root = createRoot(container); function render(items: BatchExportItem[]) { act(() => { root.render(React.createElement(Harness, { items })); }); } render(initial); return { get latest(): ReturnType { if (!latest) throw new Error("hook result not yet captured"); return latest; }, /** Re-render with a new items list (e.g. to simulate streaming). */ rerenderWith(items: BatchExportItem[]) { render(items); }, unmount() { act(() => root.unmount()); container.remove(); }, }; } describe("useBatchExport", () => { beforeEach(() => { mockExportBatch837.mockReset(); mockDownloadBlob.mockReset(); mockToastSuccess.mockReset(); mockToastWarning.mockReset(); mockToastError.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); it("auto-selects all 837P claims passed in initially", () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]); expect(h.latest.selectedClaimIds.size).toBe(3); expect(h.latest.total837InStream).toBe(3); h.unmount(); }); it("ignores 835 items when counting / selecting", () => { const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]); expect(h.latest.selectedClaimIds.size).toBe(1); expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true); expect(h.latest.total837InStream).toBe(1); h.unmount(); }); it("auto-selects a new 837P claim that streams in", () => { const h = setup([makeItem("CLM-1")]); expect(h.latest.selectedClaimIds.size).toBe(1); h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]); expect(h.latest.selectedClaimIds.size).toBe(2); expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true); h.unmount(); }); it("does not deselect existing claims when a new one streams in", () => { const h = setup([makeItem("CLM-1")]); act(() => { h.latest.onToggleSelect("CLM-1"); // deselect }); expect(h.latest.selectedClaimIds.size).toBe(0); h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]); // CLM-1 is still deselected (user's choice is preserved), CLM-2 // is auto-selected. expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false); expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true); h.unmount(); }); it("onToggleSelect adds and removes", () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]); act(() => { h.latest.onToggleSelect("CLM-1"); // toggle off }); expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false); act(() => { h.latest.onToggleSelect("CLM-1"); // toggle back on }); expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true); h.unmount(); }); it("onToggleAll deselects when everything is selected", () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]); expect(h.latest.selectedClaimIds.size).toBe(2); act(() => { h.latest.onToggleAll(); }); expect(h.latest.selectedClaimIds.size).toBe(0); h.unmount(); }); it("onToggleAll selects everything when partial / none are selected", () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]); act(() => { h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3 }); act(() => { h.latest.onToggleSelect("CLM-2"); }); act(() => { h.latest.onToggleAll(); }); expect(h.latest.selectedClaimIds.size).toBe(3); h.unmount(); }); it("onExport does nothing when no batch id is recorded", async () => { const h = setup([makeItem("CLM-1")]); await h.latest.onExport(); expect(mockExportBatch837).not.toHaveBeenCalled(); expect(mockToastError).toHaveBeenCalled(); h.unmount(); }); it("onExport does nothing when selection is empty (after recordBatchId)", async () => { const h = setup([makeItem("CLM-1")]); h.latest.recordBatchId("server-uuid-123"); act(() => { h.latest.onToggleSelect("CLM-1"); // deselect }); await h.latest.onExport(); expect(mockExportBatch837).not.toHaveBeenCalled(); h.unmount(); }); it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]); h.latest.recordBatchId("server-uuid-456"); const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/zip", }); mockExportBatch837.mockResolvedValueOnce({ blob: exportedBlob, filename: "batch-server-uuid-456-2-claims.zip", serializeErrors: [], }); await h.latest.onExport(); expect(mockExportBatch837).toHaveBeenCalledWith( "server-uuid-456", expect.arrayContaining(["CLM-1", "CLM-2"]), ); expect(mockDownloadBlob).toHaveBeenCalledTimes(1); // Regression: downloadBlob's signature is (filename, blob). An earlier // build had the args swapped, which made URL.createObjectURL receive // the filename string and throw "Overload resolution failed" — pin // the order here so a swap can't sneak back in. expect(mockDownloadBlob).toHaveBeenCalledWith( "batch-server-uuid-456-2-claims.zip", exportedBlob, ); expect(mockToastSuccess).toHaveBeenCalledWith( "Exported 2 claims as X12", expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }), ); expect(mockToastWarning).not.toHaveBeenCalled(); h.unmount(); }); it("onExport shows a warning toast with serialize-error details on partial failure", async () => { const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]); h.latest.recordBatchId("server-uuid-789"); mockExportBatch837.mockResolvedValueOnce({ blob: new Blob([new Uint8Array([1])]), filename: "batch-server-uuid-789-1-claims.zip", serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }], }); await h.latest.onExport(); expect(mockDownloadBlob).toHaveBeenCalledTimes(1); expect(mockToastSuccess).not.toHaveBeenCalled(); expect(mockToastWarning).toHaveBeenCalledWith( "Exported 1 · 1 couldn't be regenerated", expect.objectContaining({ description: expect.stringContaining("CLM-2: no raw_json"), }), ); h.unmount(); }); it("onExport shows an error toast and resets exporting=false on network failure", async () => { const h = setup([makeItem("CLM-1")]); h.latest.recordBatchId("server-uuid-abc"); mockExportBatch837.mockRejectedValueOnce(new Error("network down")); await h.latest.onExport(); expect(mockToastError).toHaveBeenCalledWith("network down"); expect(h.latest.exporting).toBe(false); h.unmount(); }); it("exporting is true while the export is in flight, false after", async () => { const h = setup([makeItem("CLM-1")]); h.latest.recordBatchId("server-uuid-xyz"); let resolveExport: (v: unknown) => void = () => {}; mockExportBatch837.mockReturnValueOnce( new Promise((res) => { resolveExport = res; }) as ReturnType, ); const exportPromise = h.latest.onExport(); // Yield once so the exporting=true state has flushed. await act(async () => { await Promise.resolve(); }); expect(h.latest.exporting).toBe(true); resolveExport({ blob: new Blob(), filename: "x.zip", serializeErrors: [], }); // The state update in onExport's `finally` block happens on a // microtask after the promise resolves; wrapping the await in // act() flushes it. await act(async () => { await exportPromise; }); expect(h.latest.exporting).toBe(false); h.unmount(); }); });