diff --git a/src/hooks/useClaimDetail.test.ts b/src/hooks/useClaimDetail.test.ts new file mode 100644 index 0000000..fb142a2 --- /dev/null +++ b/src/hooks/useClaimDetail.test.ts @@ -0,0 +1,224 @@ +// @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(); + }); +}); diff --git a/src/hooks/useClaimDetail.ts b/src/hooks/useClaimDetail.ts new file mode 100644 index 0000000..d6525b0 --- /dev/null +++ b/src/hooks/useClaimDetail.ts @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api"; +import type { ClaimDetail } from "@/types"; + +/** + * Per-claim detail drawer query (SP4). + * + * Returns `{ data, isLoading, isError, error, refetch }`: + * - `id === null` (drawer closed): the query is disabled and the hook + * short-circuits to the empty drawer state `{ data: null, isLoading: + * false, isError: false, error: null }`. `refetch` is a no-op since no + * fetch is in flight. + * - `id` is set: fetches `GET /api/claims/{id}` via `api.getClaimDetail`. + * The response is cached for 30 s so j/k navigation across claims + * reuses prior fetches within the drawer session (spec §3.4). + * - On 404: `error` is the `ApiError(status=404)` thrown by the client. + * We disable retries on 404 so the drawer can render the not-found + * state immediately rather than masking it with three back-to-back + * retries. + */ +export function useClaimDetail(id: string | null): { + data: ClaimDetail | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} { + const q = useQuery({ + queryKey: ["claim-detail", id], + queryFn: () => api.getClaimDetail(id as string), + enabled: id !== null, + staleTime: 30 * 1000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status === 404) return false; + return failureCount < 3; + }, + }); + + if (id === null) { + return { + data: null, + isLoading: false, + isError: false, + error: null, + refetch: () => {}, + }; + } + + return { + data: q.data ?? null, + isLoading: q.isLoading, + isError: q.isError, + // `q.error` is typed as `Error | null` (TanStack Query v5's default + // TError is `Error`); at runtime the api layer throws `ApiError`, + // which is a subclass of `Error`, so the cast is safe. + error: q.error, + // TanStack's refetch returns a Promise; the plan's signature wants + // a plain `() => void` so consumers can fire-and-forget. + refetch: () => { + void q.refetch(); + }, + }; +}