Files
cyclone/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx
T
Tyler 1764df0cd5 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).
2026-06-20 20:44:39 -06:00

304 lines
10 KiB
TypeScript

// @vitest-environment happy-dom
// React Query isn't used by this component (it receives a resolved
// ClaimDetail as a prop), but the surrounding test harness convention
// sets IS_REACT_ACT_ENVIRONMENT to suppress noisy act() warnings.
// Mirror the convention from ClaimDrawerSkeleton.test.tsx.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
import * as apiModule from "@/lib/api";
import * as downloadModule from "@/lib/download";
import type { ClaimDetail } from "@/types";
// Mock the api + download helpers so we can assert the wiring without
// hitting the network or the real Blob/anchor machinery. The spies live
// at module scope but `beforeEach` resets their call history so the
// happy-path test doesn't leak into the error-path test.
const serializeSpy = vi.spyOn(apiModule.api, "serializeClaim837");
const downloadSpy = vi.spyOn(downloadModule, "downloadTextFile");
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
act(() => {
root.render(element);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/**
* Minimal valid ClaimDetail fixture — every required key present so the
* component typechecks. Mirrors the SAMPLE_DETAIL in
* useClaimDetail.test.ts; only the fields the header actually reads
* (id, state, stateLabel, billedAmount) vary between tests.
*/
function makeDetail(overrides: Partial<ClaimDetail> = {}): ClaimDetail {
return {
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,
},
],
...overrides,
};
}
function renderHeader(
claim: Partial<ClaimDetail>,
onClose: () => void = vi.fn(),
onError: (message: string) => void = vi.fn()
): { container: HTMLDivElement; unmount: () => void; onClose: () => void; onError: (m: string) => void } {
const element = (
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} onError={onError} />
);
const { container, unmount } = renderIntoContainer(element);
return { container, unmount, onClose, onError };
}
describe("ClaimDrawerHeader", () => {
beforeEach(() => {
serializeSpy.mockReset();
downloadSpy.mockReset();
});
it("test_renders_claim_id_label_and_value", () => {
const { container, unmount } = renderHeader({ id: "CLM-42" });
// The eyebrow label "Claim" is rendered as uppercase text.
const text = (container.textContent ?? "").toLowerCase();
expect(text).toContain("claim");
// The claim id sits in a node tagged with data-testid="header-id".
const idEl = container.querySelector('[data-testid="header-id"]');
expect(idEl).not.toBeNull();
expect(idEl?.textContent).toBe("CLM-42");
unmount();
});
it("test_renders_state_badge_with_label", () => {
const { container, unmount } = renderHeader({
state: "submitted",
stateLabel: "Submitted",
});
const badge = container.querySelector('[data-testid="header-state"]');
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe("Submitted");
unmount();
});
it("test_state_badge_variant_matches_state", () => {
// Each state must surface the right Badge variant. We sniff the
// rendered className for the per-variant token (defined in
// badge.tsx's cva config) so the assertion is independent of the
// surrounding wrapper classes.
const cases: Array<{
state: string;
stateLabel: string;
expectedToken: string;
}> = [
// secondary → bg-muted text-muted-foreground
{ state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted text-muted-foreground" },
// default → bg-primary/15 text-primary
{ state: "accepted", stateLabel: "Accepted", expectedToken: "bg-primary/15" },
// destructive → bg-destructive/15
{ state: "denied", stateLabel: "Denied", expectedToken: "bg-destructive/15" },
// success → bg-[hsl(var(--success)/0.15)] (covered by both matched and paid)
{ state: "matched", stateLabel: "Matched", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
{ state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
// warning → bg-[hsl(var(--warning)/0.15)]
{ state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.15)]" },
];
for (const c of cases) {
const { container, unmount } = renderHeader({
state: c.state,
stateLabel: c.stateLabel,
});
const badge = container.querySelector('[data-testid="header-state"]');
expect(badge, `badge missing for state=${c.state}`).not.toBeNull();
const cls = badge?.className ?? "";
expect(
cls.includes(c.expectedToken),
`expected badge className to include "${c.expectedToken}" for state=${c.state}, got "${cls}"`
).toBe(true);
unmount();
}
});
it("test_renders_total_amount_formatted", () => {
// 123.45 → "$123.45" (the project's fmt.usdPrecise helper, which
// uses minimumFractionDigits:2 / maximumFractionDigits:2).
const { container, unmount } = renderHeader({ billedAmount: 123.45 });
const amountEl = container.querySelector('[data-testid="header-amount"]');
expect(amountEl).not.toBeNull();
expect(amountEl?.textContent).toBe("$123.45");
unmount();
});
it("test_close_button_calls_onClose", () => {
const onClose = vi.fn();
const { container, unmount } = renderHeader({}, onClose);
const closeBtn = container.querySelector(
'[data-testid="header-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
act(() => {
closeBtn?.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("test_uses_modern_palette_surface", () => {
// The drawer header anchors itself on the light surface palette
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
// <header> element is tagged with data-testid="claim-drawer-header"
// so we can sniff its className without coupling to the badge
// or close-button wrappers.
const { container, unmount } = renderHeader({});
const root = container.querySelector('[data-testid="claim-drawer-header"]');
expect(root).not.toBeNull();
const cls = root?.className ?? "";
expect(cls).toContain("bg-[color:var(--m-surface)]");
unmount();
});
it("test_download_button_fetches_and_saves_file_for_claim_id", async () => {
// Happy path: click → api.serializeClaim837(claim.id) → downloadTextFile
// with the returned text + filename. We don't actually need to wait for
// the download side-effect — we just verify the wiring (right ids,
// right payload, right mime).
serializeSpy.mockResolvedValueOnce({
text: "ISA*00*~IEA*0*1~",
filename: "claim-CLM-1.x12",
});
const { container, unmount } = renderHeader({ id: "CLM-1" });
const btn = container.querySelector(
'[data-testid="header-download-837"]'
) as HTMLButtonElement | null;
expect(btn).not.toBeNull();
await act(async () => {
btn?.click();
// Flush the microtask queue so the awaited handler resolves.
await Promise.resolve();
});
expect(serializeSpy).toHaveBeenCalledTimes(1);
expect(serializeSpy).toHaveBeenCalledWith("CLM-1");
expect(downloadSpy).toHaveBeenCalledTimes(1);
expect(downloadSpy).toHaveBeenCalledWith(
"claim-CLM-1.x12",
"text/x12",
"ISA*00*~IEA*0*1~"
);
unmount();
});
it("test_download_button_routes_api_error_through_onError", async () => {
// When serializeClaim837 throws, the header swallows the error and
// hands the message to the optional onError callback. The download
// helper must NOT be called on the failure path — that's the
// difference between "the file was corrupted in the DB" and "we just
// gave the user a broken empty file".
serializeSpy.mockRejectedValueOnce(new Error("404 Not found"));
const onError = vi.fn();
const { container, unmount } = renderHeader({ id: "CLM-1" }, vi.fn(), onError);
const btn = container.querySelector(
'[data-testid="header-download-837"]'
) as HTMLButtonElement | null;
await act(async () => {
btn?.click();
await Promise.resolve();
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith("404 Not found");
expect(downloadSpy).not.toHaveBeenCalled();
unmount();
});
});