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

690 lines
22 KiB
JavaScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const { axiosMock, fetchMock } = vi.hoisted(() => ({
axiosMock: vi.fn(),
fetchMock: vi.fn(),
}));
vi.mock("axios", () => ({
default: axiosMock,
}));
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
requestQueueState,
} from "@/services/requestQueue.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 createFetchResponse = ({ body = "", headers = {}, status = 200, statusText = "OK" } = {}) => {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [String(key).toLowerCase(), value])
);
return {
ok: status >= 200 && status < 300,
status,
statusText,
headers: {
get: vi.fn((name) => normalizedHeaders[String(name).toLowerCase()] ?? null),
},
text: vi.fn(() => Promise.resolve(body)),
};
};
describe("authenticatedRequest", () => {
beforeEach(() => {
axiosMock.mockReset();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
__configureRequestQueueForTests({
maxConcurrentGet: 1,
maxConcurrentOther: 1,
spacingMs: 0,
retryByStatusCode: {},
});
localStorage.clear();
localStorage.setItem("token", "token");
});
it("resolves scanner lpr no-plate responses without recording a failed request", async () => {
const response = {
status: 200,
data: {
success: false,
data: {
message: "No license plate detected.",
reason: "no_license_plate_detected",
},
meta: [],
includes: [],
},
};
axiosMock.mockResolvedValueOnce(response);
const catchCallable = vi.fn();
const result = await authenticatedRequest("/modules/scanner/lpr", "POST", { base64_image: "x" }, catchCallable);
expect(result).toBe(response);
expect(catchCallable).not.toHaveBeenCalled();
expect(requestQueueState.batchCompleted).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner).toEqual(
expect.objectContaining({
url: expect.stringContaining("/modules/scanner/lpr"),
success: true,
statusCode: 200,
})
);
expect(requestQueueState.errorRequests).toHaveLength(0);
});
it("passes scanner lpr FormData through without generic network accounting", async () => {
const response = {
status: 200,
data: {
success: true,
data: {
license_plate_number: "AB12345",
},
},
};
const payload = new FormData();
payload.append("image", new Blob(["frame"], { type: "image/jpeg" }), "license-plate.jpg");
axiosMock.mockResolvedValueOnce(response);
await authenticatedRequest("/modules/scanner/lpr", "POST", payload);
const axiosConfig = axiosMock.mock.calls[0]?.[0];
expect(axiosConfig).toEqual(
expect.objectContaining({
method: "POST",
data: payload,
__skipRequestQueue: true,
})
);
expect(axiosConfig.headers["Content-Type"]).toBeUndefined();
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.networkTotals.outgoingBytes).toBe(0);
expect(requestQueueState.networkTotals.ingoingResponses).toBe(0);
expect(requestQueueState.networkTotals.ingoingBytes).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.url).toContain("/modules/scanner/lpr");
});
it("passes scanner lpr raw image content type through without generic network accounting", async () => {
const response = {
status: 200,
data: {
success: true,
data: {
license_plate_number: "AB12345",
},
},
};
const payload = new Blob(["frame"], { type: "image/jpeg" });
axiosMock.mockResolvedValueOnce(response);
await authenticatedRequest("/modules/scanner/lpr", "POST", payload, null, null, {
headers: {
"Content-Type": "image/jpeg",
},
});
const axiosConfig = axiosMock.mock.calls[0]?.[0];
expect(axiosConfig).toEqual(
expect.objectContaining({
method: "POST",
data: payload,
__skipRequestQueue: true,
})
);
expect(axiosConfig.headers).toEqual(
expect.objectContaining({
"Content-Type": "image/jpeg",
})
);
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.networkTotals.ingoingResponses).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.url).toContain("/modules/scanner/lpr");
});
it("uses fetch transport for scanner raw image requests when requested", async () => {
const payload = new Blob(["frame"], { type: "image/jpeg" });
const signal = new AbortController().signal;
fetchMock.mockResolvedValueOnce(
createFetchResponse({
body: JSON.stringify({
success: true,
data: {
license_plate_number: "AB12345",
},
}),
headers: {
"content-type": "application/json",
"server-timing": "lpr_total;dur=17.000",
},
})
);
const result = await authenticatedRequest("/modules/scanner/lpr", "POST", payload, null, null, {
headers: {
"Content-Type": "image/jpeg",
},
signal,
transport: "fetch",
});
expect(result.data).toEqual({
success: true,
data: {
license_plate_number: "AB12345",
},
});
expect(axiosMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/modules/scanner/lpr"),
expect.objectContaining({
body: payload,
method: "POST",
signal,
headers: expect.objectContaining({
Authorization: "Bearer token",
"Content-Type": "image/jpeg",
}),
})
);
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.serverTiming).toBe("lpr_total;dur=17.000");
});
it("rejects failed fetch transport responses with an axios-like response", async () => {
fetchMock.mockResolvedValueOnce(
createFetchResponse({
body: JSON.stringify({
success: false,
data: {
message: "Plate Recognizer unavailable.",
},
}),
headers: {
"content-type": "application/json",
"server-timing": "lpr_total;dur=44.000",
},
status: 503,
statusText: "Service Unavailable",
})
);
const catchCallable = vi.fn();
await expect(
authenticatedRequest("/modules/scanner/lpr", "POST", new Blob(["frame"]), catchCallable, null, {
headers: {
"Content-Type": "image/jpeg",
},
transport: "fetch",
})
).rejects.toMatchObject({
name: "AxiosError",
response: {
status: 503,
statusText: "Service Unavailable",
data: {
success: false,
data: {
message: "Plate Recognizer unavailable.",
},
},
},
});
expect(catchCallable).toHaveBeenCalledWith(
expect.objectContaining({
name: "AxiosError",
response: expect.objectContaining({
status: 503,
}),
})
);
expect(requestQueueState.errorRequests[0].serverTiming).toBe("lpr_total;dur=44.000");
});
it("continues to reject scanner errors", async () => {
const error = {
response: {
status: 500,
data: {
success: false,
data: {
message: "Internal server error: Different scanner problem.",
},
},
},
};
axiosMock.mockRejectedValueOnce(error);
const catchCallable = vi.fn();
await expect(
authenticatedRequest("/modules/scanner/lpr", "POST", { base64_image: "x" }, catchCallable)
).rejects.toBe(error);
expect(catchCallable).toHaveBeenCalledWith(error);
});
it("does not send bearer credentials to untrusted absolute request URLs", async () => {
const response = { status: 200, data: { ok: true } };
axiosMock.mockResolvedValueOnce(response);
localStorage.setItem("is_subuser", "true");
localStorage.setItem("selected_customer_number", "1234");
configureReleaseRuntime({ api_base_url: "https://attacker.example/api" });
await authenticatedRequest("https://attacker.example/api/orders", "GET");
expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://attacker.example/api/orders",
headers: expect.not.objectContaining({
Authorization: "Bearer token",
"X-Customer-Number": 1234,
}),
})
);
});
it("does not expose preview-only active wash reservations as in-progress washes", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "READY_FOR_MACHINE_START",
session: {
id: 379,
status: "READY_FOR_MACHINE_START",
machine_relay_enabled: false,
machine_start_triggered: false,
wash_started_at: null,
machine_start_triggered_at: null,
machine_relay_enabled_at: null,
},
},
},
};
const thenCallable = vi.fn();
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest(
"/modules/self-serve/lane/wash/my-active-wash",
"GET",
undefined,
null,
thenCallable
);
expect(result).not.toBe(response);
expect(result.data.data).toMatchObject({
lane_id: 3,
in_progress: false,
status: "READY_FOR_MACHINE_START",
});
expect(result.data.data.session).toBe(response.data.data.session);
expect(thenCallable).toHaveBeenCalledWith(result);
});
it("keeps relay-enabled active wash responses in progress", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "MACHINE_RELAY_ENABLED",
session: {
id: 380,
status: "MACHINE_RELAY_ENABLED",
machine_relay_enabled: true,
},
},
},
};
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(result).toBe(response);
expect(result.data.data.in_progress).toBe(true);
});
it("queues requests and executes them sequentially", async () => {
const first = createDeferred();
const second = createDeferred();
const third = createDeferred();
const deferredCalls = [first, second, third];
axiosMock.mockImplementation(() => deferredCalls.shift()?.promise);
const request1 = authenticatedRequest("/one", "GET");
const request2 = authenticatedRequest("/two", "GET");
const request3 = authenticatedRequest("/three", "GET");
await flushMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(2);
expect(requestQueueState.batchTotal).toBe(3);
first.resolve({ status: 200, data: { id: 1 } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(1);
second.resolve({ status: 200, data: { id: 2 } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(0);
third.resolve({ status: 200, data: { id: 3 } });
await expect(Promise.all([request1, request2, request3])).resolves.toEqual([
{ status: 200, data: { id: 1 } },
{ status: 200, data: { id: 2 } },
{ status: 200, data: { id: 3 } },
]);
await flushMicrotasks();
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchCompleted).toBe(3);
expect(requestQueueState.batchFailed).toBe(0);
});
it("keeps self-serve hardware commands serial without blocking ordinary POST requests", async () => {
const hardwareOne = createDeferred();
const hardwareTwo = createDeferred();
const ordinaryPost = createDeferred();
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/self-serve/lane/command")) {
return hardwareOne.promise;
}
if (url.includes("/modules/self-serve/lane/relay/machine/enable")) {
return hardwareTwo.promise;
}
if (url.endsWith("/orders")) {
return ordinaryPost.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
});
const request2 = authenticatedRequest("/modules/self-serve/lane/relay/machine/enable", "post", {
lane_id: 7,
});
const request3 = authenticatedRequest("/orders", "post", {
reference: "ordinary-post",
});
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/self-serve/lane/command"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(2);
expect(requestQueueState.pending).toBe(1);
ordinaryPost.resolve({ status: 200, data: { id: 1 } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
hardwareOne.resolve({ status: 200, data: { ok: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(axiosMock.mock.calls[2][0].url).toContain("/modules/self-serve/lane/relay/machine/enable");
hardwareTwo.resolve({ status: 200, data: { ok: true } });
await expect(Promise.all([request1, request2, request3])).resolves.toHaveLength(3);
});
it("serializes POS scanner requests without blocking Stripe invoices or ordinary POS order mutations", async () => {
const scannerOne = createDeferred();
const scannerTwo = createDeferred();
const stripeInvoice = createDeferred();
const orderCreate = createDeferred();
const scannerResponses = [scannerOne, scannerTwo];
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/scanner/lpr")) {
return scannerResponses.shift()?.promise;
}
if (url.includes("/modules/stripe/invoice")) {
return stripeInvoice.promise;
}
if (url.endsWith("/orders")) {
return orderCreate.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
const request3 = authenticatedRequest("/modules/stripe/invoice", "post", { order_id: 42, email: "a@example.test" });
const request4 = authenticatedRequest("/orders", "post", { department_id: 3, reference: "pos-order" });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/scanner/lpr"),
expect.stringContaining("/modules/stripe/invoice"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(2);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.activeRequests.map((request) => request.queueGroup)).toEqual(["POS_STRIPE", null]);
expect(requestQueueState.activeRequests.some((request) => request.queueGroup === "POS_SCANNER")).toBe(false);
scannerOne.resolve({ status: 200, data: { success: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(4);
expect(axiosMock.mock.calls[3][0].url).toContain("/modules/scanner/lpr");
scannerTwo.resolve({ status: 200, data: { success: true } });
stripeInvoice.resolve({ status: 200, data: { id: "in_1" } });
orderCreate.resolve({ status: 200, data: { id: 42 } });
await expect(Promise.all([request1, request2, request3, request4])).resolves.toHaveLength(4);
});
it("drops queued scanner LPR requests when their signal aborts before Axios starts", async () => {
const scannerOne = createDeferred();
const scannerTwo = createDeferred();
axiosMock.mockImplementation(({ data }) => {
if (data?.base64_image === "image-one") {
return scannerOne.promise;
}
if (data?.base64_image === "image-two") {
return scannerTwo.promise;
}
return Promise.reject(new Error("stale scanner frame should not start"));
});
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
const controller = new AbortController();
const staleRequest = authenticatedRequest(
"/modules/scanner/lpr",
"post",
{ base64_image: "stale-image" },
null,
null,
{ signal: controller.signal }
);
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
controller.abort();
await expect(staleRequest).rejects.toMatchObject({ name: "AbortError" });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.errorRequests).toHaveLength(0);
scannerOne.resolve({ status: 200, data: { success: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
scannerTwo.resolve({ status: 200, data: { success: true } });
await expect(Promise.all([request1, request2])).resolves.toHaveLength(2);
await flushManyMicrotasks();
expect(requestQueueState.batchCompleted).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
});
it("does not record active scanner LPR aborts as failed API requests", async () => {
axiosMock.mockImplementation(
({ signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener("abort", () => {
const error = new Error("canceled");
error.code = "ERR_CANCELED";
reject(error);
});
})
);
const controller = new AbortController();
const request = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" }, null, null, {
signal: controller.signal,
});
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
controller.abort();
await expect(request).rejects.toMatchObject({ code: "ERR_CANCELED" });
await flushManyMicrotasks();
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.errorRequests).toHaveLength(0);
});
it("does not retry POS Stripe invoice mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary Stripe failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/stripe/invoice", "post", {
order_id: 42,
email: "a@example.test",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
it("does not retry non-idempotent self-serve hardware mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary backend failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
});