// @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"; import { createQueryInvalidationPrefix, createRuntimeIdentityRegistry, defineQueryNamespaceIdentity, } from "../../src/contracts/query-keys.ts"; import { bindQuery, type QueryResultMeasure, } from "../../src/contracts/server-state.ts"; import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; const RESOURCE_INVALIDATION_TOPIC = defineQueryInvalidationTopic("resource"); /** * §24.12: the scope-bound commit fence is common runtime, so it is verified * here with a local scope fixture rather than through the removable sample * feature. */ function scopeSnapshot(): CacheScopeSnapshot & { fence(): void } { let current = true; const lifetime = new AbortController(); const identities = createRuntimeIdentityRegistry({ tokenFactory: () => "scope-identity-token-0001", }); return { generation: 1, fingerprint: "scope-fingerprint-0001", identities, signal: lifetime.signal, isCurrent: () => current, fence() { current = false; lifetime.abort(); }, }; } function measureOne(): QueryResultMeasure { return { itemCount: 1, estimatedBytes: 8 }; } 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("scope-bound query commit fence", () => { it("binds the namespace-first V2 query key", () => { const scope = scopeSnapshot(); const definition = { definitionId: "reference-detail-v1", definitionVersion: 1, owner: "platform-test", namespace: "reference-resource", namespaceVersion: 1, operationId: "GET_REFERENCE_RESOURCE", profileId: "DETAIL_STANDARD" as const, measureResult: measureOne, execute: async () => ({ ok: true as const, value: "value" }), }; const bound = bindQuery(definition, "resource-1", scope); expect(bound.queryKey).toEqual([ "query", 2, "reference-resource", 1, "scope-fingerprint-0001", 1, "scope-identity-token-0001", ]); expect(bound.queryKey.slice(0, 4)).toEqual( createQueryInvalidationPrefix( defineQueryNamespaceIdentity("reference-resource", 1), ), ); }); it("discards a successful result whose scope was fenced during execution", async () => { const client = queryClient(); const scope = scopeSnapshot(); let complete: (value: ApplicationResult) => void = () => {}; const hook = renderHook( () => useApplicationQuery( bindQuery( { definitionId: "fenced-detail-v1", definitionVersion: 1, owner: "platform-test", namespace: "fenced", namespaceVersion: 1, operationId: "GET_FENCED", profileId: "DETAIL_STANDARD", measureResult: measureOne, execute: () => new Promise>((resolve) => { complete = resolve; }), }, "input", scope, ), ), { wrapper: wrapper(client) }, ); // §10.8: the scope goes stale after dispatch but before commit. scope.fence(); await act(async () => { complete({ ok: true, value: "late" }); }); await waitFor(() => expect(hook.result.current.state.failure?.kind).toBe( "SCOPE_GENERATION_CHANGED", ), ); expect(hook.result.current.data).toBeUndefined(); }); it("refuses to start when the captured scope is already stale", async () => { const client = queryClient(); const scope = scopeSnapshot(); const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); scope.fence(); const hook = renderHook( () => useApplicationQuery( bindQuery( { definitionId: "stale-detail-v1", definitionVersion: 1, owner: "platform-test", namespace: "stale", namespaceVersion: 1, operationId: "GET_STALE", profileId: "DETAIL_STANDARD", measureResult: measureOne, execute, }, "input", scope, ), ), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.state.failure?.kind).toBe( "SCOPE_GENERATION_CHANGED", ), ); expect(execute).not.toHaveBeenCalled(); }); it("rejects a result that exceeds the profile budget instead of caching it", async () => { const client = queryClient(); const scope = scopeSnapshot(); const hook = renderHook( () => useApplicationQuery( bindQuery( { definitionId: "oversized-list-v1", definitionVersion: 1, owner: "platform-test", namespace: "oversized", namespaceVersion: 1, operationId: "LIST_OVERSIZED", profileId: "VOLATILE_STATUS", // §10.4: VOLATILE_STATUS admits 1 item and 64KiB. measureResult: (): QueryResultMeasure => ({ itemCount: 2, estimatedBytes: 8, }), execute: async () => ({ ok: true as const, value: "value" }), }, "input", scope, ), ), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.state.failure?.kind).toBe( "RESULT_LIMIT_EXCEEDED", ), ); expect(hook.result.current.data).toBeUndefined(); }); it("treats a throwing measurement as a measurement failure, not a cache commit", async () => { const client = queryClient(); const scope = scopeSnapshot(); const hook = renderHook( () => useApplicationQuery( bindQuery( { definitionId: "unmeasurable-v1", definitionVersion: 1, owner: "platform-test", namespace: "unmeasurable", namespaceVersion: 1, operationId: "GET_UNMEASURABLE", profileId: "DETAIL_STANDARD", measureResult: (): QueryResultMeasure => { throw new Error("estimator defect"); }, execute: async () => ({ ok: true as const, value: "value" }), }, "input", scope, ), ), { wrapper: wrapper(client) }, ); await waitFor(() => expect(hook.result.current.state.failure?.kind).toBe( "RESULT_LIMIT_EXCEEDED", ), ); expect(hook.result.current.data).toBeUndefined(); }); }); describe("scope-bound mutation fence", () => { it("rejects a submit whose scope is already fenced", async () => { const client = queryClient(); const scope = scopeSnapshot(); const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); const hook = renderHook( () => useApplicationMutation({ definitionId: "fenced-mutation-v1", definitionVersion: 1, operationId: "CREATE_FENCED", owner: "platform-test", duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, execute, invalidate: [], }), { wrapper: wrapper(client) }, ); scope.fence(); const outcome = await hook.result.current.submit("value"); expect(outcome).toMatchObject({ ok: false, error: { kind: "SCOPE_GENERATION_CHANGED" }, }); expect(execute).not.toHaveBeenCalled(); }); it("discards a mutation result whose scope was fenced after dispatch", async () => { const client = queryClient(); const scope = scopeSnapshot(); const execute = vi.fn(async () => { scope.fence(); return { ok: true as const, value: "committed" }; }); const hook = renderHook( () => useApplicationMutation({ definitionId: "late-mutation-v1", definitionVersion: 1, operationId: "CREATE_LATE", owner: "platform-test", duplicatePolicy: "ALLOW_PARALLEL", scope, execute, invalidate: [], }), { wrapper: wrapper(client) }, ); const outcome = await hook.result.current.submit("value"); expect(outcome).toMatchObject({ ok: false, error: { kind: "SCOPE_GENERATION_CHANGED" }, }); expect(execute).toHaveBeenCalledOnce(); }); it("aborts a hung mutation when its captured scope is fenced", async () => { const client = queryClient(); const scope = scopeSnapshot(); let observedSignal: AbortSignal | undefined; const execute = vi.fn( (_input: string, context: Readonly<{ signal: AbortSignal }>) => new Promise>((resolve) => { observedSignal = context.signal; context.signal.addEventListener( "abort", () => resolve({ ok: false, error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0), }), { once: true }, ); }), ); const hook = renderHook( () => useApplicationMutation({ definitionId: "hung-mutation-v1", definitionVersion: 1, operationId: "CREATE_HUNG", owner: "platform-test", duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, execute, invalidate: [], }), { wrapper: wrapper(client) }, ); const outcome = hook.result.current.submit("value"); await waitFor(() => expect(observedSignal).toBe(scope.signal)); scope.fence(); expect(observedSignal?.aborted).toBe(true); await expect(outcome).resolves.toMatchObject({ ok: false, error: { kind: "SCOPE_GENERATION_CHANGED", effect: "MAYBE_APPLIED", retryable: false, action: "contact-support", }, }); }); }); describe("application mutation inbound bridge", () => { it("rejects a duplicate submit by default while one is active", async () => { const client = queryClient(); let complete: (value: ApplicationResult) => void = () => {}; const execute = vi.fn( () => new Promise>((resolve) => { complete = resolve; }), ); const hook = renderHook( () => useApplicationMutation({ execute }), { 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"); }); if (!first || !duplicate) throw new Error("expected two submissions"); await expect(duplicate).resolves.toMatchObject({ ok: false, error: { kind: "DUPLICATE_IN_FLIGHT" }, }); expect(execute).toHaveBeenCalledOnce(); complete({ ok: true, value: "created" }); await act(() => first as Promise>); }); 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({ // §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE. duplicatePolicy: "JOIN_IDENTICAL", 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); }); });