Files
pleno-vue/tests/unit/axios-request-queue.spec.js
T

301 lines
8.9 KiB
JavaScript

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("allows multiple ordinary POST requests by default to use available PHP workers", async () => {
__resetAxiosRequestQueueInstallerForTests();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
installAxiosRequestQueue();
const allDeferred = Array.from({ length: 5 }, () => createDeferred());
const pendingAdapters = [...allDeferred];
const adapter = () => pendingAdapters.shift()?.promise;
const requests = Array.from({ length: 5 }, (_, index) =>
axios({
url: `/queue-post-${index + 1}`,
method: "POST",
adapter,
})
);
await flushMicrotasks();
expect(requestQueueState.active).toBe(4);
expect(requestQueueState.pending).toBe(1);
for (let index = 0; index < 4; index += 1) {
allDeferred[index].resolve(createResponse({ id: index + 1 }));
}
await flushManyMicrotasks();
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(0);
allDeferred[4].resolve(createResponse({ id: 5 }));
const responses = await Promise.all(requests);
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5]);
});
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);
});
});