// @vitest-environment jsdom import { act, renderHook, waitFor, } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { type ApplicationResult, useApplicationMutation, useApplicationQuery, } from "../../src/presentation/adapters/query/application-query.ts"; import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx"; import { defineQueryInvalidationTopic, type QueryInvalidationCoordinator, } from "../../src/contracts/query-invalidation.ts"; import { createFailure } from "../../src/contracts/errors.ts"; const RESOURCE_INVALIDATION_TOPIC = defineQueryInvalidationTopic("resource"); function queryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: Infinity }, mutations: { retry: false }, }, }); } function wrapper(client: QueryClient) { const coordinator: QueryInvalidationCoordinator = { async invalidate(topics) { for (const topic of topics) { await client.invalidateQueries({ queryKey: [topic], exact: false, refetchType: "active", }); } }, beginMutation() { return { release: async () => {} }; }, async resetLocal() { await client.cancelQueries(); client.clear(); }, dispose() {}, }; return function QueryWrapper({ children }: { children: ReactNode }) { 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: ApplicationResult[] = [ { ok: true, value: ["first"] }, { ok: false, error: createFailure("SERVER_FAILURE", "LIST", 0), }, { ok: 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("normalizes an unexpected execute rejection into a safe terminal failure", async () => { const client = queryClient(); const hook = renderHook( () => useApplicationQuery({ queryKey: ["unexpected-rejection"], execute: async () => { throw new Error("private upstream detail"); }, }), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.state.base).toBe("terminal-error"), ); expect(hook.result.current.state.failure).toMatchObject({ kind: "UNKNOWN_FAILURE", operationId: "APPLICATION_QUERY", userMessageKey: "error.unknown_failure", action: "contact-support", }); expect(JSON.stringify(hook.result.current.state.failure)).not.toContain( "private upstream detail", ); }); 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 }: { signal: AbortSignal }) => 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"]); let complete: (value: ApplicationResult) => void = () => {}; const execute = vi.fn( () => new Promise>((resolve) => { complete = resolve; }), ); const hook = renderHook( () => useApplicationMutation({ execute, invalidate: [RESOURCE_INVALIDATION_TOPIC], currentData: client.getQueryData(key), optimistic: { queryKey: key, update: (previous, input) => [ ...(previous as string[]), input, ], }, }), { wrapper: wrapper(client) }, ); let first: Promise> | null = null; let duplicate: Promise> | null = null; act(() => { first = hook.result.current.submit("created"); duplicate = hook.result.current.submit("created"); }); expect(first).toBe(duplicate); await waitFor(() => 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("never joins distinct mutation inputs to the same runtime promise", async () => { const client = queryClient(); const resolvers = new Map< string, (value: ApplicationResult) => void >(); const execute = vi.fn( (input: string) => new Promise>((resolve) => { resolvers.set(input, resolve); }), ); const hook = renderHook( () => useApplicationMutation({ execute, currentData: true, }), { wrapper: wrapper(client) }, ); let first: Promise> | undefined; let second: Promise> | undefined; act(() => { first = hook.result.current.submit("first"); second = hook.result.current.submit("second"); }); expect(first).not.toBe(second); await waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); resolvers.get("first")?.({ ok: true, value: "first" }); resolvers.get("second")?.({ ok: true, value: "second" }); if (!first || !second) throw new Error("expected pending mutations"); await act(async () => { await Promise.all([first, second]); }); }); it("cancels an in-flight query before taking the optimistic snapshot", async () => { const client = queryClient(); const key = ["resource", "ordered-update"]; client.setQueryData(key, ["existing"]); let finishCancellation: () => void = () => {}; const cancellation = new Promise((resolve) => { finishCancellation = resolve; }); const cancelQueries = vi .spyOn(client, "cancelQueries") .mockImplementation(async () => cancellation); const getQueryData = vi.spyOn(client, "getQueryData"); const update = vi.fn((previous, input) => [ ...(previous as string[]), input, ]); const execute = vi.fn(async () => ({ ok: true as const, value: "created", })); const hook = renderHook( () => useApplicationMutation({ execute, currentData: ["existing"], optimistic: { queryKey: key, update }, }), { wrapper: wrapper(client) }, ); let pending: Promise> | undefined; act(() => { pending = hook.result.current.submit("created"); }); expect(cancelQueries).toHaveBeenCalledWith({ queryKey: key, exact: true, }); expect(getQueryData).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); finishCancellation(); if (!pending) throw new Error("expected pending mutation"); await act(() => pending); expect(getQueryData).toHaveBeenCalledWith(key); expect(update).toHaveBeenCalledWith(["existing"], "created"); expect(execute).toHaveBeenCalledOnce(); expect(client.getQueryData(key)).toEqual(["existing", "created"]); }); it("keeps a committed optimistic update when invalidation fails", async () => { const client = queryClient(); const key = ["resource", "committed-update"]; client.setQueryData(key, ["existing"]); vi.spyOn(client, "invalidateQueries").mockRejectedValue( new Error("cache refresh failed"), ); const hook = renderHook( () => useApplicationMutation({ execute: async () => ({ ok: true, value: "created" }), invalidate: [RESOURCE_INVALIDATION_TOPIC], currentData: ["existing"], optimistic: { queryKey: key, update: (previous, input) => [ ...(previous as string[]), input, ], }, }), { wrapper: wrapper(client) }, ); let outcome; await act(async () => { outcome = await hook.result.current.submit("created"); }); expect(outcome).toEqual({ ok: true, value: "created" }); expect(client.getQueryData(key)).toEqual(["existing", "created"]); }); it("normalizes an optimistic preparation defect without running the command", async () => { const client = queryClient(); const key = ["resource", "invalid-optimistic-update"]; client.setQueryData(key, ["existing"]); const execute = vi.fn(async () => ({ ok: true as const, value: "created", })); const hook = renderHook( () => useApplicationMutation({ execute, currentData: ["existing"], optimistic: { queryKey: key, update: () => { throw new Error("private optimistic detail"); }, }, }), { wrapper: wrapper(client) }, ); let outcome; await act(async () => { outcome = await hook.result.current.submit("created"); }); expect(outcome).toMatchObject({ ok: false, error: { kind: "UNKNOWN_FAILURE", operationId: "APPLICATION_MUTATION", userMessageKey: "error.unknown_failure", }, }); expect(JSON.stringify(outcome)).not.toContain("private optimistic detail"); expect(execute).not.toHaveBeenCalled(); expect(client.getQueryData(key)).toEqual(["existing"]); }); it("removes an optimistic cache entry when no prior data existed", async () => { const client = queryClient(); const key = ["resource", "new-optimistic-entry"]; const failure = createFailure("SERVER_FAILURE", "CREATE", 0); const hook = renderHook( () => useApplicationMutation({ execute: async () => ({ ok: false, error: failure }), currentData: true, optimistic: { queryKey: key, update: (_previous, input) => [input], }, }), { wrapper: wrapper(client) }, ); let outcome; await act(async () => { outcome = await hook.result.current.submit("temporary"); }); expect(outcome).toEqual({ ok: false, error: failure }); expect(client.getQueryData(key)).toBeUndefined(); expect(client.getQueryState(key)).toBeUndefined(); }); 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_INVALIDATION_TOPIC], currentData: client.getQueryData(key), optimistic: { queryKey: key, update: (previous, input) => [ ...(previous as string[]), 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); }); });