diff --git a/package-lock.json b/package-lock.json index 60fffb7..74eaf76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "happy-dom": "^20.10.6", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "tailwindcss-animate": "^1.0.7", @@ -2320,6 +2321,23 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2587,6 +2605,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2779,6 +2810,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -2989,6 +3033,25 @@ "node": ">=10.13.0" } }, + "node_modules/happy-dom": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -5199,6 +5262,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5216,6 +5289,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index a15d1bd..21a39a3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "happy-dom": "^20.10.6", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "tailwindcss-animate": "^1.0.7", diff --git a/src/hooks/useReconciliation.test.ts b/src/hooks/useReconciliation.test.ts new file mode 100644 index 0000000..c12ae7a --- /dev/null +++ b/src/hooks/useReconciliation.test.ts @@ -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( + 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(); + }); +}); diff --git a/src/hooks/useReconciliation.ts b/src/hooks/useReconciliation.ts new file mode 100644 index 0000000..2b70978 --- /dev/null +++ b/src/hooks/useReconciliation.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { MatchResponse, UnmatchedClaim, UnmatchedResponse } from "@/types"; + +/** + * React Query bindings for the reconciliation surface. Exposes: + * - `unmatched`: polled query for the GET /api/reconciliation/unmatched + * bucket (refreshes every 30s so the page reflects backend auto-matches + * without a manual reload). + * - `match`: mutation that POSTs /api/reconciliation/match. On success it + * invalidates every cache that could be affected by a state change: + * the unmatched bucket, the claims list, the remittances list, and the + * activity feed. + * - `unmatch`: mutation that POSTs /api/reconciliation/unmatch, with the + * same invalidation cascade. + */ +export function useReconciliation() { + const qc = useQueryClient(); + + const unmatched = useQuery({ + queryKey: ["reconciliation", "unmatched"], + queryFn: () => api.listUnmatched(), + refetchInterval: 30_000, + }); + + const match = useMutation< + MatchResponse, + Error, + { claimId: string; remitId: string } + >({ + mutationFn: (args) => api.matchRemit(args.claimId, args.remitId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["reconciliation"] }); + qc.invalidateQueries({ queryKey: ["claims"] }); + qc.invalidateQueries({ queryKey: ["remittances"] }); + qc.invalidateQueries({ queryKey: ["activity"] }); + }, + }); + + const unmatch = useMutation< + { claim: UnmatchedClaim }, + Error, + { claimId: string } + >({ + mutationFn: (args) => api.unmatchClaim(args.claimId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["reconciliation"] }); + qc.invalidateQueries({ queryKey: ["claims"] }); + qc.invalidateQueries({ queryKey: ["remittances"] }); + qc.invalidateQueries({ queryKey: ["activity"] }); + }, + }); + + return { unmatched, match, unmatch }; +}