169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useClaimDetail.test.ts so
|
|
// React doesn't log act() warnings about the createRoot render/unmount.
|
|
(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 { useRemitDetail } from "./useRemitDetail";
|
|
import { api, ApiError } from "@/lib/api";
|
|
import type { Remittance } from "@/types";
|
|
|
|
// Module-level mock: vitest hoists `vi.mock` above imports. We mock both
|
|
// `api.getRemittance` (the only method the hook calls) AND `ApiError`
|
|
// (the class used in the retry predicate) so that `instanceof ApiError`
|
|
// in the hook and `new ApiError(404, ...)` in the test resolve to the
|
|
// same class at runtime.
|
|
vi.mock("@/lib/api", () => {
|
|
class ApiError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
return {
|
|
api: {
|
|
getRemittance: vi.fn(),
|
|
},
|
|
ApiError,
|
|
};
|
|
});
|
|
|
|
/**
|
|
* Minimal renderHook shim — same pattern as `useClaimDetail.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.
|
|
*/
|
|
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);
|
|
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 Remittance — every required key present on the base
|
|
// `Remittance` interface. Mirrors the shape used by the page-level
|
|
// tests in `Remittances.test.tsx` so the type-checks line up with the
|
|
// rest of the codebase.
|
|
const SAMPLE_DETAIL: Remittance = {
|
|
id: "REM-1",
|
|
claimId: "CLM-1",
|
|
payerName: "Medicaid",
|
|
paidAmount: 100.0,
|
|
adjustmentAmount: 20.0,
|
|
receivedDate: "2026-01-15T00:00:00Z",
|
|
checkNumber: "EFT-12345",
|
|
status: "received",
|
|
};
|
|
|
|
describe("useRemitDetail", () => {
|
|
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(() => useRemitDetail(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.getRemittance).not.toHaveBeenCalled();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("fetches the remit and returns the parsed body for a valid id", async () => {
|
|
(api.getRemittance as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
SAMPLE_DETAIL
|
|
);
|
|
|
|
const { result, unmount } = renderHook(() => useRemitDetail("REM-1"));
|
|
|
|
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.getRemittance).toHaveBeenCalledTimes(1);
|
|
expect(api.getRemittance).toHaveBeenCalledWith("REM-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 "remit doesn't exist" state rather than
|
|
// masking it with three back-to-back retries.
|
|
(api.getRemittance as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
new ApiError(404, "Remittance ghost not found")
|
|
);
|
|
|
|
const { result, unmount } = renderHook(() => useRemitDetail("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.getRemittance).toHaveBeenCalledTimes(1);
|
|
|
|
unmount();
|
|
});
|
|
});
|