/** * Standalone tests for createApiFetchClient / fetchUtils.js. * * Tests the fetch wrapper layer directly: timeout, retry, deduplication, * and non-JSON response handling. Auth injection is covered separately in * security_client_headers.vitest.mjs. * * Each test uses vi.resetModules() + a dynamic import so the module-level * _pendingRequests Map starts empty and tests never interfere with each other. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function readHeader(headers, name) { if (!headers) return null; if (typeof headers.get === "function") return headers.get(name); return headers[name] ?? null; } function makeJsonFetch(body, status = 200) { return vi.fn(async () => ({ status, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => body, })); } function makeAbortableFetch() { /** * Returns a fetch mock that rejects with AbortError when the signal fires, * and never resolves otherwise. Used to test timeout behaviour with real timers. */ return vi.fn( (_, { signal } = {}) => new Promise((resolve, reject) => { if (signal?.aborted) { const err = new Error("Aborted"); err.name = "AbortError"; reject(err); return; } if (signal) { signal.addEventListener( "abort", () => { const err = new Error("Aborted"); err.name = "AbortError"; reject(err); }, { once: true }, ); } }), ); } async function importUtils() { return import("../api/fetchUtils.js"); } function makeClient(mod, overrides = {}) { return mod.createApiFetchClient({ readObsEnabled: () => false, readAuthToken: () => "", ensureWriteAuthToken: async () => "", normalizeWriteAuthFailure: (r) => r, ...overrides, }); } // --------------------------------------------------------------------------- // Module reset before every test // --------------------------------------------------------------------------- beforeEach(() => { vi.resetModules(); }); afterEach(() => { vi.restoreAllMocks(); }); // --------------------------------------------------------------------------- // delay() // --------------------------------------------------------------------------- describe("delay()", () => { it("resolves after the specified number of milliseconds", async () => { const { delay } = await importUtils(); const start = Date.now(); await delay(20); expect(Date.now() - start).toBeGreaterThanOrEqual(10); }); it("resolves immediately for 0ms without throwing", async () => { const { delay } = await importUtils(); await expect(delay(0)).resolves.toBeUndefined(); }); }); // --------------------------------------------------------------------------- // fetchAPI – timeout (real timers, short window) // --------------------------------------------------------------------------- describe("fetchAPI timeout", () => { it("returns TIMEOUT when the request exceeds timeoutMs", async () => { globalThis.fetch = makeAbortableFetch(); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); // timeoutMs is clamped to a minimum of 1 000 ms internally. const result = await fetchAPI("/mjr/am/timeout-test", { timeoutMs: 1_000 }); expect(result.ok).toBe(false); expect(result.code).toBe("TIMEOUT"); expect(result.timeout_ms).toBe(1_000); expect(result.data).toBeNull(); }, 4_000); }); // --------------------------------------------------------------------------- // fetchAPI – non-JSON / malformed response // --------------------------------------------------------------------------- describe("fetchAPI non-JSON response", () => { it("returns INVALID_RESPONSE when the server sends text/plain", async () => { globalThis.fetch = vi.fn(async () => ({ status: 500, headers: { get: () => "text/plain" }, })); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/test"); expect(result.ok).toBe(false); expect(result.code).toBe("INVALID_RESPONSE"); expect(result.status).toBe(500); }); it("returns INVALID_RESPONSE when the JSON body is not an object", async () => { globalThis.fetch = vi.fn(async () => ({ status: 200, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => null, })); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/test"); expect(result.ok).toBe(false); expect(result.code).toBe("INVALID_RESPONSE"); }); it("returns INVALID_RESPONSE when json() throws a parse error", async () => { globalThis.fetch = vi.fn(async () => ({ status: 200, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => { throw new SyntaxError("Unexpected token"); }, })); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/test"); expect(result.ok).toBe(false); expect(result.code).toBe("INVALID_RESPONSE"); }); it("preserves the server request ID for diagnostics", async () => { globalThis.fetch = vi.fn(async () => ({ status: 400, headers: { get: (name) => { if (name === "content-type") return "application/json"; if (name === "x-request-id") return "request-123"; return null; }, }, json: async () => ({ ok: false, code: "INVALID_INPUT", error: "Invalid value" }), })); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/test"); expect(result.request_id).toBe("request-123"); expect(result.status).toBe(400); }); }); describe("fetchAPI retryable HTTP status", () => { it("retries idempotent GET requests and honors Retry-After", async () => { let calls = 0; globalThis.fetch = vi.fn(async () => { calls += 1; return { status: calls === 1 ? 429 : 200, headers: { get: (name) => { if (name === "content-type") return "application/json"; if (name === "retry-after" && calls === 1) return "0"; return null; }, }, json: async () => calls === 1 ? { ok: false, code: "RATE_LIMIT", error: "Slow down" } : { ok: true, data: { recovered: true } }, }; }); const mod = await importUtils(); const { get } = makeClient(mod); const result = await get("/mjr/am/retry-after"); expect(result.ok).toBe(true); expect(calls).toBe(2); }); it("does not automatically replay write requests", async () => { globalThis.fetch = makeJsonFetch( { ok: false, code: "SERVICE_UNAVAILABLE", error: "Try later" }, 503, ); const mod = await importUtils(); const { post } = makeClient(mod); const result = await post("/mjr/am/write", { value: 1 }); expect(result.ok).toBe(false); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); }); // --------------------------------------------------------------------------- // fetchAPI – retry on network error // --------------------------------------------------------------------------- describe("fetchAPI retry on network error", () => { it("retries on TypeError network failure and succeeds on third attempt", async () => { let calls = 0; globalThis.fetch = vi.fn(async () => { calls += 1; if (calls <= 2) throw new TypeError("Failed to fetch"); return { status: 200, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => ({ ok: true, data: { retried: true } }), }; }); const mod = await importUtils(); vi.spyOn(mod, "delay").mockResolvedValue(undefined); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/retry-test"); expect(calls).toBe(3); expect(result.ok).toBe(true); }); it("returns NETWORK_ERROR with retries count after exhausting all retries", async () => { globalThis.fetch = vi.fn(async () => { throw new TypeError("Failed to fetch"); }); const mod = await importUtils(); vi.spyOn(mod, "delay").mockResolvedValue(undefined); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/retry-exhaust"); expect(result.ok).toBe(false); expect(result.code).toBe("NETWORK_ERROR"); // 1 original + 3 retries = 4 total calls expect(globalThis.fetch).toHaveBeenCalledTimes(4); expect(result.retries).toBe(3); }); it("does not retry AbortError (non-retryable)", async () => { const abortError = new Error("Aborted"); abortError.name = "AbortError"; globalThis.fetch = vi.fn(async () => { throw abortError; }); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/no-retry"); // AbortError → single attempt, no retry expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(result.ok).toBe(false); }); it("does not replay POST requests after a network failure", async () => { globalThis.fetch = vi.fn(async () => { throw new TypeError("Failed to fetch"); }); const mod = await importUtils(); const { fetchAPI } = makeClient(mod); const result = await fetchAPI("/mjr/am/write-once", { method: "POST", body: JSON.stringify({ value: 1 }), }); expect(result.code).toBe("NETWORK_ERROR"); expect(result.retries).toBe(0); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); }); // --------------------------------------------------------------------------- // get() – request deduplication // --------------------------------------------------------------------------- describe("get() request deduplication", () => { it("issues only one fetch for two concurrent identical GET requests", async () => { let resolveFetch; const blocker = new Promise((resolve) => { resolveFetch = resolve; }); globalThis.fetch = vi.fn(() => blocker); const mod = await importUtils(); const { get } = makeClient(mod); const p1 = get("/mjr/am/dedup-test"); const p2 = get("/mjr/am/dedup-test"); // Settle the fetch resolveFetch({ status: 200, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => ({ ok: true, data: { id: 1 } }), }); const [r1, r2] = await Promise.all([p1, p2]); expect(r1.ok).toBe(true); expect(r2.ok).toBe(true); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); it("issues a second fetch after the first completes", async () => { globalThis.fetch = makeJsonFetch({ ok: true, data: [] }); const mod = await importUtils(); const { get } = makeClient(mod); await get("/mjr/am/sequential"); await get("/mjr/am/sequential"); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it("bypasses deduplication when dedupe:false is passed", async () => { let resolveFetch; const blocker = new Promise((resolve) => { resolveFetch = resolve; }); globalThis.fetch = vi.fn(() => blocker); const mod = await importUtils(); const { get } = makeClient(mod); const p1 = get("/mjr/am/no-dedup"); const p2 = get("/mjr/am/no-dedup", { dedupe: false }); // Both are in-flight — fetch should have been called twice await Promise.resolve(); // flush microtasks await Promise.resolve(); resolveFetch({ status: 200, headers: { get: (name) => (name === "content-type" ? "application/json" : null) }, json: async () => ({ ok: true, data: null }), }); await Promise.all([p1, p2]); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); }); // --------------------------------------------------------------------------- // post() – basic smoke test // --------------------------------------------------------------------------- describe("post()", () => { it("sends a JSON body and returns the parsed response", async () => { globalThis.fetch = makeJsonFetch({ ok: true, data: { created: true } }); const mod = await importUtils(); const { post } = makeClient(mod); const result = await post("/mjr/am/asset/rating", { rating: 4 }); expect(result.ok).toBe(true); const [, opts] = globalThis.fetch.mock.calls[0]; expect(opts.method).toBe("POST"); expect(JSON.parse(opts.body)).toEqual({ rating: 4 }); expect(readHeader(opts.headers, "Content-Type")).toBe("application/json"); }); it("returns NETWORK_ERROR when fetch throws on a POST", async () => { globalThis.fetch = vi.fn(async () => { throw new TypeError("Failed to fetch"); }); const mod = await importUtils(); vi.spyOn(mod, "delay").mockResolvedValue(undefined); const { post } = makeClient(mod); const result = await post("/mjr/am/asset/tags", { tags: [] }); expect(result.ok).toBe(false); expect(result.code).toBe("NETWORK_ERROR"); }); });