feat(frontend): useReconciliation hook with react-query invalidation cascade
This commit is contained in:
Generated
+95
@@ -29,6 +29,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"happy-dom": "^20.10.6",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -2320,6 +2321,23 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"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": "^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": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -2779,6 +2810,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/es-errors": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
@@ -2989,6 +3033,25 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5216,6 +5289,28 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"happy-dom": "^20.10.6",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<UnmatchedResponse>({
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user