Files
cyclone/src/hooks/useBatchExport.test.ts
T
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00

289 lines
9.6 KiB
TypeScript

// @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<typeof useBatchExport> | 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<typeof useBatchExport> {
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<typeof api.exportBatch837>,
);
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();
});
});