// @vitest-environment happy-dom // React Query's internal state updates need an `act`-aware environment or // it logs noisy warnings. The reconciliation test sets this up the same // way; mirror it here for consistency. (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 { describe, expect, it, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useClaimDetail } from "./useClaimDetail"; import { api, ApiError } from "@/lib/api"; // Module-level mock: vitest hoists `vi.mock` above imports. We mock both // `api.getClaimDetail` (the only method the hook calls) AND `ApiError` // (the class used in the hook's retry predicate) so that `instanceof // ApiError` in the hook and `new ApiError(404, ...)` in the test resolve // to the same class instance at runtime. vi.mock("@/lib/api", () => { class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } return { api: { getClaimDetail: vi.fn(), }, ApiError, }; }); /** * Minimal `renderHook` shim — same pattern as `useReconciliation.test.ts`. * The project doesn't ship `@testing-library/react`, so we wire a Probe * component into a real `createRoot` and read its return value through * a shared `result` object. `act` flushes React's state updates between * micro-tasks. */ function renderHook( useCallback: () => TResult ): { result: { current: TResult | undefined }; unmount: () => void; } { const result: { current: TResult | undefined } = { current: undefined }; const container = document.createElement("div"); document.body.appendChild(container); // `retry: false` on the default options is just a safety net; the hook // installs its own retry predicate that short-circuits on 404. const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); function Probe() { result.current = useCallback(); return null; } const root: Root = createRoot(container); act(() => { root.render( React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe)) ); }); return { result, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** Poll the result until `predicate` is true or we time out. */ async function waitForState( predicate: () => boolean, timeoutMs = 1000 ): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start > timeoutMs) { throw new Error(`waitForState: predicate did not hold within ${timeoutMs}ms`); } await act(async () => { await Promise.resolve(); }); } } // Minimal valid ClaimDetail — every required key present, with the // typical `matchedRemittance: null` (unpaired claim, the dominant // happy-path render). Same shape the api.test.ts getClaimDetail test uses. const SAMPLE_DETAIL = { 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, }, ], } as const; describe("useClaimDetail", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns the empty drawer state and does not fetch when id is null", () => { // The drawer is closed → no network call, no loading, no error. // The hook must short-circuit BEFORE the queryFn is ever invoked. const { result, unmount } = renderHook(() => useClaimDetail(null)); expect(result.current).toBeDefined(); expect(result.current?.data).toBeNull(); expect(result.current?.isLoading).toBe(false); expect(result.current?.isError).toBe(false); expect(result.current?.error).toBeNull(); expect(typeof result.current?.refetch).toBe("function"); expect(api.getClaimDetail).not.toHaveBeenCalled(); unmount(); }); it("fetches the claim and returns the parsed body for a valid id", async () => { (api.getClaimDetail as unknown as ReturnType).mockResolvedValue( SAMPLE_DETAIL ); const { result, unmount } = renderHook(() => useClaimDetail("CLM-1")); // Wait until the query resolves — `data` transitions from null to the // sample detail. We also assert `isLoading: false` once that happens. await waitForState( () => result.current?.data !== null && result.current?.data !== undefined && result.current?.isLoading === false ); expect(result.current?.data).toEqual(SAMPLE_DETAIL); expect(result.current?.isError).toBe(false); expect(result.current?.error).toBeNull(); expect(api.getClaimDetail).toHaveBeenCalledTimes(1); expect(api.getClaimDetail).toHaveBeenCalledWith("CLM-1"); unmount(); }); it("surfaces a 404 ApiError as isError=true with the original error object", async () => { // The hook's retry predicate must give up immediately on 404 — the // drawer needs to render the "claim doesn't exist" state rather than // masking it with three back-to-back retries. (api.getClaimDetail as unknown as ReturnType).mockRejectedValue( new ApiError(404, "Claim ghost not found") ); const { result, unmount } = renderHook(() => useClaimDetail("ghost")); await waitForState(() => result.current?.isError === true); expect(result.current?.data).toBeNull(); expect(result.current?.isLoading).toBe(false); expect(result.current?.isError).toBe(true); expect(result.current?.error).toBeInstanceOf(ApiError); expect((result.current?.error as ApiError).status).toBe(404); // 404s must not retry — exactly one fetch attempt. expect(api.getClaimDetail).toHaveBeenCalledTimes(1); unmount(); }); });