// @vitest-environment happy-dom // Tell React this is an `act`-aware test environment so react-query's // internal state updates flush through without noisy console warnings. (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 { useReconciliation } from "./useReconciliation"; import { api } from "@/lib/api"; // Module-level mock: vitest hoists `vi.mock` calls above imports. vi.mock("@/lib/api", () => ({ api: { listUnmatched: vi.fn(), matchRemit: vi.fn(), unmatchClaim: vi.fn(), }, })); /** * Minimal renderHook equivalent using react-dom/client + react-dom/test-utils. * Lets the test assert on the hook's latest return value after one or more * `act()`-wrapped state updates. We use this instead of * `@testing-library/react` because the project doesn't ship a DOM test * library yet — adding one just for this hook would inflate the dev-deps * tree for a single call site. */ 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); 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(); }, }; } /** Flush microtasks + react state updates until the predicate holds 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(); }); } } describe("useReconciliation", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns the unmatched query + match + unmatch mutations", async () => { ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [], remittances: [] }); const { result, unmount } = renderHook(() => useReconciliation()); await waitForState(() => result.current?.unmatched.isSuccess === true); expect(result.current?.unmatched.data).toEqual({ claims: [], remittances: [] }); expect(typeof result.current?.match.mutate).toBe("function"); expect(typeof result.current?.match.mutateAsync).toBe("function"); expect(typeof result.current?.unmatch.mutate).toBe("function"); expect(typeof result.current?.unmatch.mutateAsync).toBe("function"); unmount(); }); it("match.mutateAsync calls api.matchRemit with the claim + remit ids", async () => { ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [], remittances: [] }); ( api.matchRemit as unknown as ReturnType ).mockResolvedValue({ claim: { id: "c1" }, match: { id: 1, strategy: "manual" }, }); const { result, unmount } = renderHook(() => useReconciliation()); await waitForState(() => result.current?.unmatched.isSuccess === true); let mutateResult: unknown; await act(async () => { mutateResult = await result.current!.match.mutateAsync({ claimId: "c1", remitId: "r1", }); }); expect(api.matchRemit).toHaveBeenCalledWith("c1", "r1"); expect(mutateResult).toEqual({ claim: { id: "c1" }, match: { id: 1, strategy: "manual" }, }); await waitForState(() => result.current?.match.isSuccess === true); unmount(); }); it("unmatch.mutateAsync calls api.unmatchClaim with the claim id", async () => { ( api.listUnmatched as unknown as ReturnType ).mockResolvedValue({ claims: [], remittances: [] }); ( api.unmatchClaim as unknown as ReturnType ).mockResolvedValue({ claim: { id: "c1" }, }); const { result, unmount } = renderHook(() => useReconciliation()); await waitForState(() => result.current?.unmatched.isSuccess === true); await act(async () => { await result.current!.unmatch.mutateAsync({ claimId: "c1" }); }); expect(api.unmatchClaim).toHaveBeenCalledWith("c1"); await waitForState(() => result.current?.unmatch.isSuccess === true); unmount(); }); });