diff --git a/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx b/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx index 366111a..b9489c7 100644 --- a/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx +++ b/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx @@ -7,10 +7,19 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, it, vi } from "vitest"; +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; @@ -104,16 +113,21 @@ function makeDetail(overrides: Partial = {}): ClaimDetail { function renderHeader( claim: Partial, - onClose: () => void = vi.fn() -): { container: HTMLDivElement; unmount: () => void; onClose: () => void } { + onClose: () => void = vi.fn(), + onError: (message: string) => void = vi.fn() +): { container: HTMLDivElement; unmount: () => void; onClose: () => void; onError: (m: string) => void } { const element = ( - + ); const { container, unmount } = renderIntoContainer(element); - return { container, unmount, onClose }; + 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" }); @@ -226,4 +240,64 @@ describe("ClaimDrawerHeader", () => { 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(); + }); }); diff --git a/src/components/ClaimDrawer/ClaimDrawerHeader.tsx b/src/components/ClaimDrawer/ClaimDrawerHeader.tsx index 3be96b2..135bee6 100644 --- a/src/components/ClaimDrawer/ClaimDrawerHeader.tsx +++ b/src/components/ClaimDrawer/ClaimDrawerHeader.tsx @@ -1,6 +1,9 @@ -import { X } from "lucide-react"; +import { useState } from "react"; +import { Download, X } from "lucide-react"; import { Badge, type BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { downloadTextFile } from "@/lib/download"; import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ClaimDetail } from "@/types"; @@ -8,6 +11,13 @@ import type { ClaimDetail } from "@/types"; type ClaimDrawerHeaderProps = { claim: ClaimDetail; onClose: () => void; + /** + * Optional toast callback for surfacing download failures (network error, + * 404 if the claim was deleted out from under us, 422 if the stored + * payload can't be regenerated). Defaults to a no-op so consumers that + * don't wire a toast still get the happy-path download. + */ + onError?: (message: string) => void; }; /** @@ -44,7 +54,32 @@ function badgeVariantFor(state: string): BadgeProps["variant"] { * badge color encodes state so the user can read the drawer's status * at a glance without scrolling. */ -export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) { +export function ClaimDrawerHeader({ + claim, + onClose, + onError, +}: ClaimDrawerHeaderProps) { + // Local "is downloading" flag so the button can show feedback while the + // fetch is in flight. The header is rendered above the fold, so a stuck + // spinner is the difference between "the click did something" and "did + // my click even register?" + const [downloading, setDownloading] = useState(false); + + async function handleDownload() { + if (downloading) return; + setDownloading(true); + try { + const { text, filename } = await api.serializeClaim837(claim.id); + downloadTextFile(filename, "text/x12", text); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to download 837 file."; + onError?.(message); + } finally { + setDownloading(false); + } + } + return (
- {/* Right: state badge + total amount + close */} -
+ {/* Right: state badge + total amount + download + close */} +
+