// @vitest-environment jsdom import { act, renderHook, waitFor, } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { useApplicationMutation, useApplicationQuery, } from "../../src/presentation/adapters/query/application-query.js"; import { createFailure } from "../../src/contracts/errors.js"; function queryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: Infinity }, mutations: { retry: false }, }, }); } /** @param {QueryClient} client */ function wrapper(client) { /** @param {{children: React.ReactNode}} props */ return function QueryWrapper({ children }) { return ( {children} ); }; } describe("application query inbound bridge", () => { it("latches a background failure over stale data and clears it on retry success", async () => { const client = queryClient(); const responses = [ { ok: /** @type {const} */ (true), value: ["first"] }, { ok: /** @type {const} */ (false), error: createFailure("SERVER_FAILURE", "LIST", 0), }, { ok: /** @type {const} */ (true), value: ["recovered"] }, ]; const execute = vi.fn(async () => responses.shift() ?? responses[0]); const hook = renderHook( () => useApplicationQuery({ queryKey: ["resource", "list"], execute, }), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.data).toEqual(["first"])); await act(() => hook.result.current.retry()); await waitFor(() => expect(hook.result.current.state.indicator).toBe("stale-degraded"), ); expect(hook.result.current.state.base).toBe("success"); expect(hook.result.current.data).toEqual(["first"]); await act(() => hook.result.current.retry()); await waitFor(() => expect(hook.result.current.data).toEqual(["recovered"]), ); expect(hook.result.current.state.indicator).toBeNull(); expect(execute).toHaveBeenCalledTimes(3); }); it("projects an initial application failure into terminal state", async () => { const client = queryClient(); const failure = createFailure("FORBIDDEN", "LIST", 0); const hook = renderHook( () => useApplicationQuery({ queryKey: ["forbidden"], execute: async () => ({ ok: false, error: failure }), }), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.state.base).toBe("terminal-error"), ); expect(hook.result.current.state.failure).toBe(failure); }); it("passes cancellation to the application and does not retain an unmounted error", async () => { const client = queryClient(); let aborted = false; const execute = vi.fn( ({ signal }) => new Promise((resolve) => { signal.addEventListener( "abort", () => { aborted = true; resolve({ ok: false, error: createFailure("REQUEST_ABORTED", "LIST", 0), }); }, { once: true }, ); }), ); const hook = renderHook( () => useApplicationQuery({ queryKey: ["cancelled"], execute, }), { wrapper: wrapper(client) }, ); await waitFor(() => expect(execute).toHaveBeenCalledOnce()); hook.unmount(); await waitFor(() => expect(aborted).toBe(true)); expect(client.getQueryState(["cancelled"])?.status).not.toBe("error"); }); }); describe("application mutation inbound bridge", () => { it("deduplicates submit and commits one optimistic mutation", async () => { const client = queryClient(); const key = ["resource", "list"]; client.setQueryData(key, ["existing"]); /** @type {(value: {ok: true, value: string}) => void} */ let complete = () => {}; const execute = vi.fn( () => new Promise((resolve) => { complete = resolve; }), ); const hook = renderHook( () => useApplicationMutation({ execute, invalidate: [["resource"]], currentData: client.getQueryData(key), optimistic: { queryKey: key, update: (previous, input) => [ .../** @type {string[]} */ (previous), input, ], }, }), { wrapper: wrapper(client) }, ); /** @type {ReturnType | null} */ let first = null; /** @type {ReturnType | null} */ let duplicate = null; act(() => { first = hook.result.current.submit("created"); duplicate = hook.result.current.submit("created"); }); expect(first).toBe(duplicate); expect(client.getQueryData(key)).toEqual(["existing", "created"]); await waitFor(() => expect(execute).toHaveBeenCalledOnce()); await waitFor(() => expect(hook.result.current.state.indicator).toBe("mutation-pending"), ); complete({ ok: true, value: "created" }); if (!first) throw new Error("expected pending mutation"); await act(() => first); expect(client.getQueryState(key)?.isInvalidated).toBe(true); await waitFor(() => expect(hook.result.current.state.indicator).toBeNull(), ); }); it("rolls optimistic data back and exposes a resolvable conflict", async () => { const client = queryClient(); const key = ["resource", "list"]; client.setQueryData(key, ["existing"]); const conflict = createFailure("CONFLICT", "CREATE", 0); const hook = renderHook( () => useApplicationMutation({ execute: async () => ({ ok: false, error: conflict }), invalidate: [["resource"]], currentData: client.getQueryData(key), optimistic: { queryKey: key, update: (previous, input) => [ .../** @type {string[]} */ (previous), input, ], }, }), { wrapper: wrapper(client) }, ); let outcome; await act(async () => { outcome = await hook.result.current.submit("conflicting"); }); expect(outcome).toEqual({ ok: false, error: conflict }); expect(client.getQueryData(key)).toEqual(["existing"]); expect(hook.result.current.state.indicator).toBe("mutation-conflict"); expect(hook.result.current.state.overlay).toMatchObject({ mutationPending: false, mutationConflict: true, }); await act(() => hook.result.current.resolveConflict()); expect(hook.result.current.state.indicator).toBeNull(); expect(client.getQueryState(key)?.isInvalidated).toBe(true); }); });