9bca4b608a
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.
289 lines
9.6 KiB
TypeScript
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();
|
|
});
|
|
});
|