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.
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { ApiError, api } from "./api";
|
|
|
|
const originalFetch = global.fetch;
|
|
const mockFetch = vi.fn();
|
|
|
|
describe("api GET helpers", () => {
|
|
beforeEach(() => {
|
|
global.fetch = mockFetch as unknown as typeof fetch;
|
|
mockFetch.mockReset();
|
|
});
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("listBatches hits /api/batches and unwraps body.items", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ items: [] }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
const out = await api.listBatches();
|
|
expect(out).toEqual([]);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
expect(called).toContain("/api/batches");
|
|
expect(called).not.toContain("?");
|
|
});
|
|
|
|
it("listClaims builds the right URL with status + sort + order", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ items: [], total: 0, returned: 0, has_more: false }),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
)
|
|
);
|
|
await api.listClaims({
|
|
status: "submitted",
|
|
sort: "billedAmount",
|
|
order: "desc",
|
|
});
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
expect(called).toContain("/api/claims?");
|
|
expect(called).toContain("status=submitted");
|
|
expect(called).toContain("sort=billedAmount");
|
|
expect(called).toContain("order=desc");
|
|
});
|
|
|
|
it("getBatch returns the result with the right id in the URL", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
envelope: {
|
|
sender_id: "X",
|
|
receiver_id: "Y",
|
|
control_number: "1",
|
|
transaction_date: "2026-01-01",
|
|
},
|
|
claims: [],
|
|
summary: {},
|
|
kind: "835",
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
)
|
|
);
|
|
const out = await api.getBatch("abc");
|
|
expect(out).toBeTruthy();
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
expect(called).toContain("/api/batches/abc");
|
|
});
|
|
|
|
it("getClaimDetail hits /api/claims/{id} and returns the parsed body", async () => {
|
|
// Minimal valid ClaimDetail shape — every spec-mandated key present,
|
|
// including the optional `matchedRemittance: null` for an unpaired
|
|
// claim (the dominant UI render path).
|
|
const body = {
|
|
id: "CLM-1",
|
|
batchId: "B-1",
|
|
state: "submitted",
|
|
stateLabel: "Submitted",
|
|
billedAmount: 123.45,
|
|
patientName: "Jane Doe",
|
|
providerNpi: "1234567890",
|
|
providerName: "Acme Clinic",
|
|
payerName: "Medicaid",
|
|
payerId: "MEDCO",
|
|
submissionDate: "2026-01-15T00:00:00Z",
|
|
serviceDateFrom: "2026-01-10",
|
|
serviceDateTo: "2026-01-10",
|
|
parsedAt: "2026-01-15T00:00:01Z",
|
|
diagnoses: [{ code: "E11.9", qualifier: "ABK" }],
|
|
serviceLines: [
|
|
{
|
|
lineNumber: 1,
|
|
procedureQualifier: "HC",
|
|
procedureCode: "99213",
|
|
modifiers: [],
|
|
charge: 100.0,
|
|
units: 1,
|
|
unitType: "UN",
|
|
serviceDate: "2026-01-10",
|
|
},
|
|
],
|
|
parties: {
|
|
billingProvider: {
|
|
name: "Acme Clinic",
|
|
npi: "1234567890",
|
|
taxId: "12-3456789",
|
|
address: {
|
|
line1: "123 Main St",
|
|
line2: null,
|
|
city: "Denver",
|
|
state: "CO",
|
|
zip: "80202",
|
|
},
|
|
},
|
|
subscriber: {
|
|
firstName: "Jane",
|
|
lastName: "Doe",
|
|
memberId: "M-1",
|
|
dob: null,
|
|
gender: "F",
|
|
},
|
|
payer: { name: "Medicaid", id: "MEDCO" },
|
|
},
|
|
validation: { passed: true, errors: [], warnings: [] },
|
|
rawSegments: [["ISA*00*"]],
|
|
matchedRemittance: null,
|
|
stateHistory: [
|
|
{
|
|
kind: "claim_submitted",
|
|
ts: "2026-01-15T00:00:00Z",
|
|
batchId: "B-1",
|
|
remittanceId: null,
|
|
},
|
|
],
|
|
};
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
const out = await api.getClaimDetail("CLM-1");
|
|
expect(out).toEqual(body);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
// URL must include the encoded id, no trailing query string.
|
|
expect(called).toContain("/api/claims/CLM-1");
|
|
expect(called).not.toContain("?");
|
|
});
|
|
|
|
it("getClaimDetail throws an ApiError on 404", async () => {
|
|
// The backend raises `{"error":"Not found","detail":"Claim X not found"}`
|
|
// on missing ids (see api.py:get_claim_detail_endpoint). The client
|
|
// must surface it as an ApiError so the drawer can distinguish
|
|
// "doesn't exist" from a transient fetch failure (spec §3.4).
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
|
|
{
|
|
status: 404,
|
|
headers: { "content-type": "application/json" },
|
|
}
|
|
)
|
|
);
|
|
let caught: unknown;
|
|
try {
|
|
await api.getClaimDetail("ghost");
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(ApiError);
|
|
expect((caught as ApiError).status).toBe(404);
|
|
});
|
|
|
|
it("serializeClaim837 returns the regenerated text + filename from Content-Disposition", async () => {
|
|
const x12 = "ISA*00* *00* *ZZ*CYCLONE *ZZ*RECEIVER *260620*1200*^*00501*000000001*0*P*:~IEA*0*000000001~";
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(x12, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/x12",
|
|
// Backend emits: attachment; filename="claim-{id}.x12"
|
|
"content-disposition": 'attachment; filename="claim-CLM-1.x12"',
|
|
},
|
|
})
|
|
);
|
|
const out = await api.serializeClaim837("CLM-1");
|
|
expect(out.text).toBe(x12);
|
|
expect(out.filename).toBe("claim-CLM-1.x12");
|
|
// URL must hit the new SP8 endpoint with the encoded id.
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
expect(called).toContain("/api/claims/CLM-1/serialize-837");
|
|
// No Accept header negotiation needed — backend always returns text/x12.
|
|
const init = mockFetch.mock.calls[0][1] as RequestInit | undefined;
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
expect(headers.Accept).toBeUndefined();
|
|
});
|
|
|
|
it("serializeClaim837 falls back to a default filename if Content-Disposition is missing", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response("ISA*00*~", { status: 200 })
|
|
);
|
|
const out = await api.serializeClaim837("CLM-2");
|
|
expect(out.filename).toBe("claim-CLM-2.x12");
|
|
});
|
|
|
|
it("serializeClaim837 throws ApiError on 404", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
|
|
{ status: 404, headers: { "content-type": "application/json" } }
|
|
)
|
|
);
|
|
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
|
|
});
|
|
|
|
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
|
|
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(zipBytes, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/zip",
|
|
"content-disposition":
|
|
'attachment; filename="batch-abc123-3-claims.zip"',
|
|
},
|
|
})
|
|
);
|
|
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
|
|
expect(out.filename).toBe("batch-abc123-3-claims.zip");
|
|
expect(out.blob).toBeInstanceOf(Blob);
|
|
// POST to the batch-scoped endpoint with claim_ids in the body.
|
|
const called = mockFetch.mock.calls[0][0] as string;
|
|
expect(called).toContain("/api/batches/abc123/export-837");
|
|
const init = mockFetch.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe("POST");
|
|
const body = JSON.parse(init.body as string);
|
|
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
|
|
});
|
|
|
|
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
|
|
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
|
const errs = [
|
|
{ claim_id: "CLM-2", reason: "no raw_json" },
|
|
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
|
|
];
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(zipBytes, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/zip",
|
|
"content-disposition":
|
|
'attachment; filename="batch-abc123-1-claims.zip"',
|
|
"x-cyclone-serialize-errors": JSON.stringify(errs),
|
|
},
|
|
})
|
|
);
|
|
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
|
|
expect(out.serializeErrors).toEqual(errs);
|
|
});
|
|
|
|
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
|
|
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(zipBytes, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/zip",
|
|
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
|
|
},
|
|
})
|
|
);
|
|
const out = await api.exportBatch837("x", ["CLM-1"]);
|
|
expect(out.serializeErrors).toEqual([]);
|
|
});
|
|
|
|
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
|
|
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(zipBytes, {
|
|
status: 200,
|
|
headers: { "content-type": "application/zip" },
|
|
})
|
|
);
|
|
const out = await api.exportBatch837("xyz", ["CLM-1"]);
|
|
expect(out.filename).toBe("batch-xyz-claims.zip");
|
|
});
|
|
|
|
it("exportBatch837 throws ApiError on 404", async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
|
|
{ status: 404, headers: { "content-type": "application/json" } }
|
|
)
|
|
);
|
|
await expect(
|
|
api.exportBatch837("ghost", ["CLM-1"])
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
});
|
|
|
|
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
|
|
await expect(api.exportBatch837("any", [])).rejects.toThrow();
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
|
|
// The backend can wrap structured errors as
|
|
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
|
|
// client must walk into the nested `detail` so the user sees the
|
|
// human-readable message rather than a blob of raw JSON.
|
|
mockFetch.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
detail: {
|
|
error: "Missing param",
|
|
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
|
|
},
|
|
}),
|
|
{ status: 400, headers: { "content-type": "application/json" } }
|
|
)
|
|
);
|
|
let caught: unknown;
|
|
try {
|
|
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(ApiError);
|
|
expect((caught as ApiError).status).toBe(400);
|
|
// The unwrapped message must surface — not the raw JSON.
|
|
expect((caught as ApiError).message).toBe(
|
|
"Both ?a=<batch_id> and ?b=<batch_id> are required."
|
|
);
|
|
});
|
|
|
|
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
|
|
// Defense-in-depth: the hook's `enabled` guard should prevent this
|
|
// from ever firing, but if it ever does, the client must refuse to
|
|
// make a malformed request and surface a clear local error.
|
|
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
|
|
status: 400,
|
|
message: expect.stringContaining("?a=<batch_id>"),
|
|
});
|
|
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
|
|
status: 400,
|
|
message: expect.stringContaining("?b=<batch_id>"),
|
|
});
|
|
// Network must not be touched.
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|