import axios from "axios"; import { beforeEach, afterEach, describe, expect, it } from "vitest"; import { API_URL } from "@/config.js"; import { __configureRequestQueueForTests, __resetRequestQueueForTests, requestQueueState, } from "@/services/requestQueue.js"; import { __resetAxiosRequestQueueInstallerForTests, installAxiosRequestQueue, } from "@/services/installAxiosRequestQueue.js"; import { __resetReleaseTimelineForTests, __setReleaseTimelineTransportForTests, configureReleaseRuntime, flushReleaseTimelineEvents, } from "@/services/releaseTimeline.js"; const flushMicrotasks = async () => { await Promise.resolve(); await Promise.resolve(); }; const flushManyMicrotasks = async (rounds = 10) => { for (let index = 0; index < rounds; index += 1) { await Promise.resolve(); } }; const createDeferred = () => { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; }; const createResponse = (data) => ({ status: 200, statusText: "OK", headers: {}, config: {}, data, }); describe("axios request queue interceptor", () => { beforeEach(() => { __resetAxiosRequestQueueInstallerForTests(); __resetRequestQueueForTests(); __resetReleaseTimelineForTests(); __configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 }); installAxiosRequestQueue(); }); afterEach(() => { __resetAxiosRequestQueueInstallerForTests(); __resetRequestQueueForTests(); __resetReleaseTimelineForTests(); }); it("queues plain axios requests system-wide", async () => { const first = createDeferred(); const second = createDeferred(); const adapters = [first, second]; const requestOne = axios({ url: "/queue-test-1", method: "GET", adapter: () => adapters.shift()?.promise, }); const requestTwo = axios({ url: "/queue-test-2", method: "GET", adapter: () => adapters.shift()?.promise, }); await flushMicrotasks(); expect(requestQueueState.active).toBe(1); expect(requestQueueState.pending).toBe(1); expect(requestQueueState.batchTotal).toBe(2); first.resolve(createResponse({ id: 1 })); await flushManyMicrotasks(); expect(requestQueueState.active).toBe(1); expect(requestQueueState.pending).toBe(0); second.resolve(createResponse({ id: 2 })); const [responseOne, responseTwo] = await Promise.all([requestOne, requestTwo]); expect(responseOne.data).toEqual({ id: 1 }); expect(responseTwo.data).toEqual({ id: 2 }); await flushMicrotasks(); expect(requestQueueState.active).toBe(0); expect(requestQueueState.pending).toBe(0); expect(requestQueueState.batchCompleted).toBe(2); expect(requestQueueState.batchFailed).toBe(0); }); it("omits failed axios request bodies from timeline events when capture is disabled", async () => { const sentBodies = []; __setReleaseTimelineTransportForTests(async (body) => { sentBodies.push(body); return { accepted: body.events.length }; }); await expect( axios({ url: `${API_URL}/auth/login`, method: "POST", data: { customer_number: "cust-123", password: "PlaintextP@ssw0rd!", g_recaptcha_response: "recaptcha-response-secret", }, adapter: async (config) => { expect(config.data).toContain('"password":"PlaintextP@ssw0rd!"'); throw { response: { status: 401, statusText: "Unauthorized", data: { token: "response-token" }, headers: {}, }, message: "Request failed", }; }, }) ).rejects.toBeTruthy(); await flushReleaseTimelineEvents(); expect(sentBodies).toHaveLength(1); expect(sentBodies[0].events[0].type).toBe("request_failed"); expect(sentBodies[0].events[0].payload.request.data).toBe("[capture-disabled]"); expect(sentBodies[0].events[0].payload.response.data).toBe("[capture-disabled]"); expect(JSON.stringify(sentBodies[0])).not.toContain("PlaintextP@ssw0rd!"); expect(JSON.stringify(sentBodies[0])).not.toContain("recaptcha-response-secret"); }); it("rewrites default API requests to the active release API before dispatch", async () => { configureReleaseRuntime({ channel: { slug: "canary" }, versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 }, api_base_url: "https://api-v2.truckwash.io/canary/api", }); let dispatchedUrl = ""; let dispatchedHeaders = {}; const response = await axios({ url: `${API_URL}/orders`, method: "GET", adapter: async (config) => { dispatchedUrl = config.url; dispatchedHeaders = config.headers || {}; return createResponse({ ok: true }); }, }); expect(response.data).toEqual({ ok: true }); expect(dispatchedUrl).toBe("https://api-v2.truckwash.io/canary/api/orders"); expect(dispatchedHeaders["X-Release-Trace"]).toBe("test-trace"); expect(dispatchedHeaders["X-Release-Channel"]).toBe("canary"); expect(dispatchedHeaders["X-Frontend-Version"]).toEqual(expect.any(String)); }); it("does not rewrite requests that opt out for release manager control calls", async () => { configureReleaseRuntime({ channel: { slug: "canary" }, versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 }, api_base_url: "https://api-v2.truckwash.io/canary/api", }); let dispatchedUrl = ""; await axios({ url: `${API_URL}/superuser/releases`, method: "GET", __skipReleaseApiRewrite: true, adapter: async (config) => { dispatchedUrl = config.url; return createResponse({ ok: true }); }, }); expect(dispatchedUrl).toBe(`${API_URL}/superuser/releases`); }); it("allows 5 concurrent GET requests", async () => { __configureRequestQueueForTests({ maxConcurrentGet: 5, maxConcurrentOther: 1, spacingMs: 0 }); const allDeferred = Array.from({ length: 6 }, () => createDeferred()); const pendingAdapters = [...allDeferred]; const adapter = () => pendingAdapters.shift()?.promise; const requests = Array.from({ length: 6 }, (_, index) => axios({ url: `/queue-get-${index + 1}`, method: "GET", adapter, }) ); await flushMicrotasks(); expect(requestQueueState.active).toBe(5); expect(requestQueueState.pending).toBe(1); for (let index = 0; index < 5; index += 1) { allDeferred[index].resolve(createResponse({ id: index + 1 })); } await flushManyMicrotasks(); expect(requestQueueState.active).toBe(1); expect(requestQueueState.pending).toBe(0); allDeferred[5].resolve(createResponse({ id: 6 })); const responses = await Promise.all(requests); expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]); }); it("retries configured response status codes", async () => { __configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0, retryByStatusCode: { 429: 2 }, retryDelayBaseMs: 0, retryDelayMaxMs: 0, retryDelayJitterMs: 0, }); let attempts = 0; const request = axios({ url: "/queue-retry-429", method: "GET", adapter: async () => { attempts += 1; if (attempts < 3) { throw { response: { status: 429 } }; } return createResponse({ ok: true, attempts }); }, }); const response = await request; expect(response.data).toEqual({ ok: true, attempts: 3 }); expect(attempts).toBe(3); expect(requestQueueState.batchCompleted).toBe(1); expect(requestQueueState.batchFailed).toBe(0); }); });