// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { type ApplicationResult, useApplicationQuery, } from "../../src/presentation/adapters/query/application-query.ts"; import { createQueryInvalidationPrefix, defineQueryNamespaceIdentity, } from "../../src/contracts/query-keys.ts"; import { bindQuery, type QueryResultMeasure, } from "../../src/contracts/server-state.ts"; import { measureOne, queryClient, scopeSnapshot, wrapper, } from "./application-query-fixture.tsx"; describe("scope-bound query commit fence", () => { it("binds the namespace-first V2 query key", () => { const scope = scopeSnapshot(); const definition = { definitionId: "resource-detail-v1", definitionVersion: 1, owner: "platform-test", namespace: "resource", namespaceVersion: 1, operationId: "GET_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, "resource", 1, "scope-fingerprint-0001", 1, "scope-identity-token-0001", ]); expect(bound.queryKey.slice(0, 4)).toEqual( createQueryInvalidationPrefix( defineQueryNamespaceIdentity("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(); }); });