feat(sp8): Claim drawer Download 837 button

Three pieces:

- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
  helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
  drops the BOM prepend (which would corrupt the ISA segment).

- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
  GET /api/claims/{id}/serialize-837, pulls the suggested filename from
  Content-Disposition (falls back to claim-{id}.x12 if the header is
  missing). Throws ApiError on non-2xx so callers can branch on .status.

- ClaimDrawerHeader: Download icon button between the amount and the
  close button. Click → api.serializeClaim837 → downloadTextFile.
  Disabled + 'Downloading 837 file' aria-label while the fetch is in
  flight so the click feels responsive. Optional onError prop surfaces
  fetch failures; defaults to a no-op so existing callers stay clean.

Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
This commit is contained in:
Tyler
2026-06-20 20:44:39 -06:00
parent 2893676c0b
commit 1764df0cd5
6 changed files with 336 additions and 9 deletions
+42
View File
@@ -175,4 +175,46 @@ describe("api GET helpers", () => {
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);
});
});