feat(frontend): useReconciliation hook with react-query invalidation cascade
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// @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<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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Flush microtasks + react state updates until the predicate holds 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("useReconciliation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns the unmatched query + match + unmatch mutations", async () => {
|
||||
(
|
||||
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).mockResolvedValue({ claims: [], remittances: [] });
|
||||
(
|
||||
api.matchRemit as unknown as ReturnType<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).mockResolvedValue({ claims: [], remittances: [] });
|
||||
(
|
||||
api.unmatchClaim as unknown as ReturnType<typeof vi.fn>
|
||||
).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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user