feat(frontend): useClaimDetail hook with null-id short-circuit
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user