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; 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= and ?b= 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= and ?b= 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="), }); await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({ status: 400, message: expect.stringContaining("?b="), }); // Network must not be touched. expect(mockFetch).not.toHaveBeenCalled(); }); });