feat(frontend): useClaimDetail hook with null-id short-circuit

This commit is contained in:
Tyler
2026-06-20 10:12:10 -06:00
parent 44e1c4616d
commit 1dfd4b58c0
2 changed files with 287 additions and 0 deletions
+224
View File
@@ -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<TResult>(
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<void> {
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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
+63
View File
@@ -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<ClaimDetail>({
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();
},
};
}