import { describe, expect, it, vi } from "vitest"; import { createHttpClient } from "../../src/adapters/http/client.js"; import { entityQueryKeys, TEST_HTTP_CONTRACT, } from "../helpers/http-contract-fixture.js"; /** @param {unknown} data */ function successResponse(data) { return Response.json({ success: true, data, meta: { requestId: "request-1", traceId: "trace-1" }, }); } /** @param {number} status */ function failureResponse(status) { return Response.json( { success: false, error: { code: "TEMPORARY" }, meta: { requestId: "request-1", traceId: "trace-1" }, }, { status }, ); } function immediateClock() { return { now: () => 0, sleep: async () => {} }; } function recordingScheduler() { const callbacks = /** @type {Array<() => void>} */ ([]); return { callbacks, setTimeout: vi.fn((callback) => { callbacks.push(callback); return callbacks.length - 1; }), clearTimeout: vi.fn(), }; } /** @param {Parameters[0]} options */ function testClient(options) { return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options }); } describe("HTTP operation execution contract", () => { it("sends parsed search/body values and aligns canonical query identity", async () => { const requests = /** @type {Request[]} */ ([]); const scheduler = recordingScheduler(); const fetcher = vi.fn(async (request) => { requests.push(/** @type {Request} */ (request)); if (/** @type {Request} */ (request).method === "POST") { return successResponse({ id: "created", name: "Trimmed" }); } return successResponse([]); }); const client = testClient({ baseUrl: "https://api.test", fetcher, clock: immediateClock(), scheduler, }); const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 }; await client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", searchParams: filters, }); await client.execute({ operationId: "CREATE_ENTITY", routeId: "TEST_ROUTE", body: { name: " Trimmed " }, idempotencyKey: "logical-command", }); expect(requests[0].url).toBe( "https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new", ); expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters); await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" }); expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command"); expect(scheduler.setTimeout).toHaveBeenCalledTimes(2); expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2); }); it("performs no fetch or timer work for invalid request input", async () => { const fetcher = vi.fn(); const scheduler = recordingScheduler(); const client = testClient({ baseUrl: "https://api.test", fetcher, scheduler, }); await expect( client.execute({ operationId: "CREATE_ENTITY", routeId: "TEST_ROUTE", body: { name: " " }, }), ).resolves.toMatchObject({ ok: false, error: { kind: "VALIDATION_REJECTED" }, }); expect(fetcher).not.toHaveBeenCalled(); expect(scheduler.setTimeout).not.toHaveBeenCalled(); expect(scheduler.clearTimeout).not.toHaveBeenCalled(); }); it.each([ [0, 1], [1, 2], [2, 3], ])( "applies runtime max retry count %i as %i total attempts", async (maxRetryAttempts, totalAttempts) => { const fetcher = vi.fn(async () => failureResponse(503)); const scheduler = recordingScheduler(); const client = testClient({ baseUrl: "https://api.test", fetcher, clock: immediateClock(), scheduler, maxRetryAttempts, }); await expect( client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", }), ).resolves.toMatchObject({ ok: false, error: { kind: "SERVER_FAILURE" }, }); expect(fetcher).toHaveBeenCalledTimes(totalAttempts); expect(scheduler.setTimeout).toHaveBeenCalledTimes(totalAttempts); expect(scheduler.clearTimeout).toHaveBeenCalledTimes(totalAttempts); }, ); it("distinguishes a runtime timeout from caller navigation abort and cleans listeners", async () => { const scheduler = recordingScheduler(); const fetcher = vi.fn( (request) => new Promise((_resolve, reject) => { /** @type {Request} */ (request).signal.addEventListener( "abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }, ); }), ); const client = testClient({ baseUrl: "https://api.test", fetcher, scheduler, maxRetryAttempts: 0, }); const timeoutResult = client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", }); await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1)); scheduler.callbacks[0](); await expect(timeoutResult).resolves.toMatchObject({ ok: false, error: { kind: "REQUEST_TIMEOUT" }, }); const caller = new AbortController(); const add = vi.spyOn(caller.signal, "addEventListener"); const remove = vi.spyOn(caller.signal, "removeEventListener"); const abortResult = client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", signal: caller.signal, }); await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); caller.abort("navigation"); await expect(abortResult).resolves.toMatchObject({ ok: false, error: { kind: "REQUEST_ABORTED" }, }); expect(add).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledOnce(); expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2); }); it("never retries unsafe, non-retryable status, or schema failures", async () => { const unsafeOperation = /** @type {const} */ ({ method: "POST", path: "/api/unsafe", operationId: "UNSAFE", auth: "none", timeoutMs: null, idempotency: "none", retry: "never", requestSource: "none", requestSchema: "unused", responseSchema: "EntityPayload", owner: "test", }); const unsafeFetch = vi.fn(async () => failureResponse(503)); const unsafeClient = testClient({ baseUrl: "https://api.test", fetcher: unsafeFetch, clock: immediateClock(), getOperation: () => unsafeOperation, }); await unsafeClient.execute({ operationId: "UNSAFE", routeId: "TEST", }); expect(unsafeFetch).toHaveBeenCalledOnce(); const statusFetch = vi.fn(async () => failureResponse(500)); const statusClient = testClient({ baseUrl: "https://api.test", fetcher: statusFetch, clock: immediateClock(), }); await statusClient.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", }); expect(statusFetch).toHaveBeenCalledOnce(); const schemaFetch = vi.fn(async () => successResponse([{ id: "one", name: 42 }]), ); const schemaScheduler = recordingScheduler(); const schemaClient = testClient({ baseUrl: "https://api.test", fetcher: schemaFetch, clock: immediateClock(), scheduler: schemaScheduler, }); await schemaClient.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE", }); expect(schemaFetch).toHaveBeenCalledOnce(); expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce(); }); });