Files
cyclone/src/lib/api.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

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();
});
});