// @vitest-environment jsdom import { enableAutoUnmount, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import axios from "axios"; import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { API_URL } from "@/config.js"; import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js"; import { __configureRequestQueueForTests, __resetRequestQueueForTests, enqueueRequest, reportComponentMissingPermission, requestQueueState, } from "@/services/requestQueue.js"; enableAutoUnmount(afterEach); 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 triggerShiftTriplePress = async () => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })); await flushMicrotasks(); }; describe("RequestQueueProgress", () => { const resetSessionUserState = () => { SessionUser.isSubuser.value = false; SessionUser.user.display_name.value = null; SessionUser.user.customer_number.value = null; SessionUser.user.group_id.value = null; SessionUser.user.email.value = null; SessionUser.user.phone.number.value = null; SessionUser.subuser.id.value = null; SessionUser.subuser.username.value = null; SessionUser.subuser.name.value = null; SessionUser.subuser.email.value = null; SessionUser.subuser.grants.value = []; SessionUser.subuser.selectedGrantCustomerNumber.value = null; }; beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); __resetReleaseTimelineForTests(); __resetRequestQueueForTests(); __configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 }); vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, status: 200 })) ); resetSessionUserState(); }); afterEach(() => { __resetRequestQueueForTests(); __resetReleaseTimelineForTests(); resetSessionUserState(); localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); it("is hidden by default and becomes visible + expanded on shift 3 times", async () => { const wrapper = mount(RequestQueueProgress); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false); const first = createDeferred(); const second = createDeferred(); const requestOne = enqueueRequest(() => first.promise); const requestTwo = enqueueRequest(() => second.promise); await flushMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false); await triggerShiftTriplePress(); expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("1 active, 1 queued"); expect(wrapper.find("[data-testid='request-queue-progress-details']").exists()).toBe(true); first.resolve({ status: 200 }); await flushManyMicrotasks(); expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("Request queue:"); second.resolve({ status: 200 }); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); expect(wrapper.get("[data-testid='request-queue-progress']").text()).toContain("Request queue complete: 2/2"); vi.advanceTimersByTime(2600); await flushMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true); }); it("stays hidden until shift 3 times even for small batches", async () => { const wrapper = mount(RequestQueueProgress); const onlyRequest = createDeferred(); const request = enqueueRequest(() => onlyRequest.promise); await flushManyMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false); await triggerShiftTriplePress(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true); onlyRequest.resolve({ status: 200 }); await request; await flushManyMicrotasks(); }); it("closes when escape is pressed while open", async () => { const wrapper = mount(RequestQueueProgress); const first = createDeferred(); const second = createDeferred(); const requestOne = enqueueRequest(() => first.promise, { method: "GET", url: "/esc-close-1" }); const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/esc-close-2" }); await flushManyMicrotasks(); await triggerShiftTriplePress(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); await flushManyMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false); first.resolve({ status: 200 }); second.resolve({ status: 200 }); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); }); it("dispatches system-search close event on shift 3x for superuser", async () => { vi.spyOn(SessionUser, "canAccessSuperUser").mockReturnValue(true); const closeEventListener = vi.fn(); window.addEventListener("system-search:close", closeEventListener); mount(RequestQueueProgress); await triggerShiftTriplePress(); expect(closeEventListener).toHaveBeenCalled(); expect(closeEventListener.mock.calls.some(([event]) => event?.type === "system-search:close")).toBe(true); window.removeEventListener("system-search:close", closeEventListener); }); it("expands upward with active endpoint details, method icon and timer", async () => { const wrapper = mount(RequestQueueProgress); const first = createDeferred(); const second = createDeferred(); const requestOne = enqueueRequest(() => first.promise, { method: "POST", url: "/orders" }); const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/order/items" }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); expect(wrapper.find("[data-testid='request-queue-errors-box']").exists()).toBe(true); expect(wrapper.find("[data-testid='request-queue-missing-permissions-box']").exists()).toBe(true); expect(wrapper.find("[data-testid='request-queue-runtime-box']").exists()).toBe(true); expect(wrapper.find("[data-testid='request-queue-user-box']").exists()).toBe(true); const details = wrapper.get("[data-testid='request-queue-progress-details']"); expect(details.text()).toContain("Active requests"); expect(details.text()).toContain("POST"); expect(details.text()).toContain("/orders"); expect(details.text()).toMatch(/ms|s/); expect(details.find(".fa-plus-circle").exists()).toBe(true); const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']"); expect(runtimeBox.text()).toContain("API URL"); expect(runtimeBox.text()).toContain("Current host"); expect(runtimeBox.text()).toContain("Environment"); expect(runtimeBox.text()).toContain("Commit"); expect(runtimeBox.text()).toContain("Version time"); expect(runtimeBox.text()).toContain("Outgoing requests"); expect(runtimeBox.text()).toContain("Ingoing responses"); expect(runtimeBox.text()).toContain("Outgoing bandwidth"); expect(runtimeBox.text()).toContain("Ingoing bandwidth"); const userBox = wrapper.get("[data-testid='request-queue-user-box']"); expect(userBox.text()).toContain("User type"); expect(userBox.text()).toContain("User"); first.resolve({ status: 200 }); second.resolve({ status: 200 }); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); }); it("tracks runtime ingoing/outgoing request counts and bandwidth totals", async () => { const wrapper = mount(RequestQueueProgress); const first = createDeferred(); const second = createDeferred(); const requestOne = enqueueRequest(() => first.promise, { method: "POST", url: "/runtime-metrics-a", requestData: { headers: { "X-Trace": "a" }, data: { payload: "first" }, }, }); const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/runtime-metrics-b", requestData: { params: { page: 1, limit: 10 }, }, }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); first.resolve({ status: 200, data: { ok: true, item: "a" } }); second.resolve({ status: 200, data: { ok: true, item: "b" } }); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); expect(requestQueueState.networkTotals.outgoingRequests).toBe(2); expect(requestQueueState.networkTotals.ingoingResponses).toBe(2); expect(requestQueueState.networkTotals.outgoingBytes).toBeGreaterThan(0); expect(requestQueueState.networkTotals.ingoingBytes).toBeGreaterThan(0); const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']"); expect(runtimeBox.text()).toContain("Outgoing requests"); expect(runtimeBox.text()).toContain("Ingoing responses"); expect(runtimeBox.text()).toContain("Outgoing bandwidth"); expect(runtimeBox.text()).toContain("Ingoing bandwidth"); }); it("shows the active session release bundle, app URLs, and connected services to release operators", async () => { localStorage.setItem("superuser_token", "su-token"); configureReleaseRuntime({ generated_at: "2026-05-19T09:30:00.000Z", trace_id: "trace-request-panel", channel: { slug: "canary", name: "Canary", default_channel: false }, versions: { frontend: { version_label: "frontend-canary", commit_sha: "c0ffee0000001111222233334444555566667777", repository: "truckwash/front-end-vue", branch: "release/canary", status: "deployed", }, api: { version_label: "api-canary", commit_sha: "feedface00001111222233334444555566667777", repository: "truckwash/backend-php", branch: "release/canary", status: "deployed", }, service_set: { id: 53, name: "Canary isolated stack", stack: { frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" }, api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" }, database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" }, redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" }, minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" }, }, }, bundle_id: 31, bundle: { id: 31, version_label: "canary-bundle", status: "deployed" }, }, urls: { frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", api_base_url: "https://api-v2.truckwash.io/canary/api", }, availability: { configured: true, missing: [], status: "ready" }, }); const wrapper = mount(RequestQueueProgress); await triggerShiftTriplePress(); await flushManyMicrotasks(); const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']"); expect(runtimeBox.text()).toContain("Session release"); expect(runtimeBox.text()).toContain("Canary"); expect(runtimeBox.text()).toContain("trace-request-panel"); expect(runtimeBox.text()).toContain("#31 canary-bundle"); expect(runtimeBox.text()).toContain("Canary isolated stack"); expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain("frontend-canary"); expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain( "https://api-v2.truckwash.io/canary/frontend" ); expect(wrapper.get("[data-testid='request-queue-release-app-api']").text()).toContain("api-canary"); expect(wrapper.get("[data-testid='request-queue-release-service-database']").text()).toContain("canary-db #54"); expect(wrapper.get("[data-testid='request-queue-release-service-redis']").text()).toContain("canary-redis #55"); expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56"); }); it("hides release infrastructure metadata from low-privileged subusers", async () => { SessionUser.isSubuser.value = true; SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: ["order_read"] }]; SessionUser.subuser.selectedGrantCustomerNumber.value = 12345; configureReleaseRuntime({ generated_at: "2026-05-19T09:30:00.000Z", trace_id: "trace-low-privilege", channel: { slug: "canary", name: "Canary", default_channel: false }, versions: { frontend: { version_label: "frontend-canary", repository: "truckwash/front-end-vue", branch: "release/canary", status: "deployed", }, api: { version_label: "api-canary", status: "deployed" }, service_set: { id: 53, name: "Canary isolated stack", stack: { database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid", coolify_service_uuid: "database-service-uuid", health_url: "https://coolify.internal/health/db", }, }, }, bundle_id: 31, }, urls: { frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", api_base_url: "https://api-v2.truckwash.io/canary/api", }, }); const wrapper = mount(RequestQueueProgress); await triggerShiftTriplePress(); await flushManyMicrotasks(); const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']"); expect(runtimeBox.text()).toContain("Runtime details"); expect(runtimeBox.text()).not.toContain("Session release"); expect(runtimeBox.text()).not.toContain("trace-low-privilege"); expect(runtimeBox.text()).not.toContain("Canary isolated stack"); expect(runtimeBox.text()).not.toContain("canary-db"); expect(runtimeBox.text()).not.toContain("database-resource-uuid"); expect(runtimeBox.text()).not.toContain("coolify.internal"); expect(runtimeBox.text()).not.toContain("api-v2.truckwash.io"); expect(runtimeBox.text()).toContain("Restricted to release operators"); expect(wrapper.find("[data-testid='request-queue-release-service-database']").exists()).toBe(false); }); it("shows subuser details in the user box", async () => { SessionUser.isSubuser.value = true; SessionUser.subuser.name.value = "Sub User"; SessionUser.subuser.id.value = 77; SessionUser.subuser.email.value = "subuser@example.com"; SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: [] }]; SessionUser.subuser.selectedGrantCustomerNumber.value = 12345; const wrapper = mount(RequestQueueProgress); const first = createDeferred(); const second = createDeferred(); const requestOne = enqueueRequest(() => first.promise, { method: "GET", url: "/subuser-check-1" }); const requestTwo = enqueueRequest(() => second.promise, { method: "GET", url: "/subuser-check-2" }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); const userBox = wrapper.get("[data-testid='request-queue-user-box']"); expect(userBox.text()).toContain("User type"); expect(userBox.text()).toContain("Subuser"); expect(userBox.text()).toContain("Sub User"); expect(userBox.text()).toContain("subuser@example.com"); expect(userBox.text()).toContain("12345"); first.resolve({ status: 200 }); second.resolve({ status: 200 }); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); }); it("shows ping and bookings insights in the bottom of the middle panel", async () => { const wrapper = mount(RequestQueueProgress); const pingRequest = createDeferred(); const bookingsRequest = createDeferred(); const scannerRequest = createDeferred(); const requestOne = enqueueRequest(() => pingRequest.promise, { method: "GET", url: "/ping" }); const requestTwo = enqueueRequest(() => bookingsRequest.promise, { method: "GET", url: "/order-bookings" }); const requestThree = enqueueRequest(() => scannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); const scannerServerTiming = "lpr_client_capture;dur=12.345, lpr_client_preflight;dur=1.500, lpr_client_visual_fingerprint;dur=0.750, lpr_client_draw;dur=2.250, lpr_client_encode;dur=8.500, lpr_client_frame_width;dur=300.000, lpr_client_frame_height;dur=225.000, lpr_client_frame_bytes;dur=12345.000, lpr_cache_miss;dur=1.000, lpr_cache;dur=0.750, lpr_local;dur=2.500, lpr_upstream_processing;dur=30.250, lpr_total;dur=42.500, lpr_upstream_total;dur=40.000"; pingRequest.resolve({ status: 200 }); bookingsRequest.resolve({ status: 200 }); scannerRequest.resolve({ status: 200, headers: { "server-timing": scannerServerTiming, }, }); await Promise.all([requestOne, requestTwo, requestThree]); await flushManyMicrotasks(); const insights = wrapper.get("[data-testid='request-queue-bottom-request-insights']"); expect(insights.text()).toContain("Ping"); expect(insights.text()).toContain("Bookings"); expect(insights.text()).toContain("Scanner"); expect(insights.text()).toContain("browser"); expect(insights.text()).toContain("cap 12.345 ms"); expect(insights.text()).toContain("prep 1.5 ms"); expect(insights.text()).toContain("vf 0.75 ms"); expect(insights.text()).toContain("draw 2.25 ms"); expect(insights.text()).toContain("enc 8.5 ms"); expect(insights.text()).toContain("img 300x225"); expect(insights.text()).toContain("bytes 12.1 KB"); expect(insights.text()).toContain("cache miss"); expect(insights.text()).toContain("cache 0.75 ms"); expect(insights.text()).toContain("local 2.5 ms"); expect(insights.text()).toContain("proc 30.25 ms"); expect(insights.text()).toContain("up 40 ms"); expect(insights.text()).toContain("srv 42.5 ms"); const pingInsight = wrapper.get("[data-testid='request-queue-insight-ping']"); const bookingsInsight = wrapper.get("[data-testid='request-queue-insight-bookings']"); const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']"); expect(pingInsight.text()).toMatch(/now|s ago|m ago/); expect(bookingsInsight.text()).toMatch(/now|s ago|m ago/); expect(scannerInsight.text()).toMatch(/now|s ago|m ago/); expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe( scannerServerTiming ); }); it("shows scanner cache hits without upstream timing", async () => { const wrapper = mount(RequestQueueProgress); const scannerRequest = createDeferred(); const request = enqueueRequest(() => scannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); const scannerServerTiming = "lpr_client_capture;dur=9.500, lpr_cache_hit;dur=1.000, lpr_cache;dur=0.430, lpr_local;dur=1.200, lpr_total;dur=1.200"; scannerRequest.resolve({ status: 200, headers: { "server-timing": scannerServerTiming, }, }); await request; await flushManyMicrotasks(); const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']"); expect(scannerInsight.text()).toContain("cache hit"); expect(scannerInsight.text()).toContain("cache 0.43 ms"); expect(scannerInsight.text()).toContain("local 1.2 ms"); expect(scannerInsight.text()).toContain("srv 1.2 ms"); expect(scannerInsight.text()).not.toContain("up "); expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe( scannerServerTiming ); }); it("shows scanner insights when successful frames skip the generic recent request list", async () => { const wrapper = mount(RequestQueueProgress); const staleRecentRequest = createDeferred(); const scannerRequest = createDeferred(); const staleRequest = enqueueRequest(() => staleRecentRequest.promise, { method: "POST", url: "/modules/scanner/lpr", }); const request = enqueueRequest(() => scannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", insightKey: "scanner", recordRecentOnSuccess: false, }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); staleRecentRequest.resolve({ status: 200, headers: { "server-timing": "lpr_client_capture;dur=99.000, lpr_total;dur=120.000", }, }); await staleRequest; await flushManyMicrotasks(); vi.advanceTimersByTime(5); const scannerServerTiming = "lpr_client_capture;dur=8.000, lpr_total;dur=24.000"; scannerRequest.resolve({ status: 200, headers: { "server-timing": scannerServerTiming, }, }); await request; await flushManyMicrotasks(); expect(requestQueueState.recentRequests).toHaveLength(1); expect(requestQueueState.recentRequests[0].serverTiming).toContain("dur=99.000"); const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']"); expect(scannerInsight.text()).toContain("browser"); expect(scannerInsight.text()).toContain("cap 8 ms"); expect(scannerInsight.text()).toContain("srv 24 ms"); expect(scannerInsight.text()).not.toContain("99 ms"); expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe( scannerServerTiming ); }); it("updates scanner request insights without replacing the insights container", async () => { const firstScannerRequest = createDeferred(); const secondScannerRequest = createDeferred(); const firstRequest = enqueueRequest(() => firstScannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", insightKey: "scanner", recordRecentOnSuccess: false, }); await flushManyMicrotasks(); firstScannerRequest.resolve({ status: 200, headers: { "server-timing": "lpr_total;dur=24.000", }, }); await firstRequest; await flushManyMicrotasks(); const requestInsightsRef = requestQueueState.requestInsights; expect(requestInsightsRef.scanner?.serverTiming).toBe("lpr_total;dur=24.000"); const secondRequest = enqueueRequest(() => secondScannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", insightKey: "scanner", recordRecentOnSuccess: false, }); await flushManyMicrotasks(); secondScannerRequest.resolve({ status: 200, headers: { "server-timing": "lpr_total;dur=18.000", }, }); await secondRequest; await flushManyMicrotasks(); expect(requestQueueState.requestInsights).toBe(requestInsightsRef); expect(requestQueueState.requestInsights.scanner?.serverTiming).toBe("lpr_total;dur=18.000"); }); it("shows scanner queue wait separately from browser request time", async () => { const wrapper = mount(RequestQueueProgress); const blockingRequest = createDeferred(); const scannerRequest = createDeferred(); const requestOne = enqueueRequest(() => blockingRequest.promise, { method: "POST", url: "/blocking-post" }); const requestTwo = enqueueRequest(() => scannerRequest.promise, { method: "POST", url: "/modules/scanner/lpr", }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); vi.advanceTimersByTime(37); blockingRequest.resolve({ status: 200 }); await requestOne; await flushManyMicrotasks(); vi.advanceTimersByTime(63); scannerRequest.resolve({ status: 200, headers: { "server-timing": "lpr_total;dur=21.000", }, }); await requestTwo; await flushManyMicrotasks(); const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']"); expect(scannerInsight.text()).toContain("browser 63 ms"); expect(scannerInsight.text()).toContain("queue 37 ms"); expect(scannerInsight.text()).toContain("net 42 ms"); expect(scannerInsight.text()).toContain("srv 21 ms"); }); it("does not emit recursive update errors while processing many requests", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); mount(RequestQueueProgress); await triggerShiftTriplePress(); await flushManyMicrotasks(); const requests = Array.from({ length: 30 }, (_, index) => enqueueRequest(async () => ({ status: 200, data: { index } }), { method: "GET", url: `/bulk-recursive-check-${index}`, }) ); await Promise.all(requests); await flushManyMicrotasks(30); const hasRecursiveError = [...errorSpy.mock.calls, ...warnSpy.mock.calls].some((call) => call.join(" ").includes("Maximum recursive updates exceeded") ); expect(hasRecursiveError).toBe(false); }); it("stores server timing headers with completed and failed request entries", async () => { __configureRequestQueueForTests({ errorHistoryLimit: 2, retryByStatusCode: {}, }); const completed = enqueueRequest( async () => ({ status: 200, headers: { "server-timing": "lpr_total;dur=42.500", }, data: { ok: true }, }), { method: "POST", url: "/modules/scanner/lpr", } ); const failed = enqueueRequest( async () => { throw { message: "Scanner failed", response: { status: 500, headers: { "Server-Timing": "lpr_upstream;dur=450.000", }, data: { ok: false }, }, }; }, { method: "POST", url: "/modules/scanner/lpr", } ).catch((error) => error); await Promise.all([completed, failed]); await flushManyMicrotasks(); expect(requestQueueState.recentRequests).toHaveLength(2); expect(requestQueueState.recentRequests.find((request) => request.success === true)?.serverTiming).toBe( "lpr_total;dur=42.500" ); expect(requestQueueState.recentRequests.find((request) => request.success === false)?.serverTiming).toBe( "lpr_upstream;dur=450.000" ); expect(requestQueueState.errorRequests[0].serverTiming).toBe("lpr_upstream;dur=450.000"); }); it("stores errors with request and response payloads and respects configured error limit", async () => { __configureRequestQueueForTests({ errorHistoryLimit: 1, missingPermissionsLimit: 1, retryByStatusCode: {}, payloadMaxChars: 8000, }); const wrapper = mount(RequestQueueProgress); const requestOne = enqueueRequest( async () => { throw { message: "First failure", response: { status: 500, data: { reason: "first" } }, }; }, { method: "POST", url: "/first-error", requestData: { data: { requestBody: "first" } }, } ).catch((error) => error); const requestTwo = enqueueRequest( async () => { throw { message: "Second failure", response: { status: 403, data: { success: false, data: { message: "Missing permission(s)", permissions: ["department_notification_sms_get"], }, meta: [], includes: [], }, }, }; }, { method: "PATCH", url: "/second-error", requestData: { data: { requestBody: "second" } }, } ).catch((error) => error); await Promise.all([requestOne, requestTwo]); await flushManyMicrotasks(); expect(requestQueueState.errorRequests.length).toBe(1); expect(requestQueueState.errorRequests[0].url).toBe("/second-error"); expect(requestQueueState.errorRequests[0].requestText).toContain("requestBody"); expect(requestQueueState.errorRequests[0].responseText).toContain("Missing permission(s)"); expect(requestQueueState.missingPermissions.length).toBe(1); expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get"); await triggerShiftTriplePress(); await flushManyMicrotasks(); const errorsBox = wrapper.get("[data-testid='request-queue-errors-box']"); expect(errorsBox.text()).toContain("Errors (1)"); expect(errorsBox.text()).toContain("/second-error"); expect(errorsBox.text()).toContain("Request and response data"); expect(errorsBox.text()).toContain("Request"); expect(errorsBox.text()).toContain("Response"); const missingPermissionsBox = wrapper.get("[data-testid='request-queue-missing-permissions-box']"); expect(missingPermissionsBox.text()).toContain("department_notification_sms_get"); await wrapper.get("[data-testid='request-queue-clear-errors']").trigger("click"); await flushManyMicrotasks(); expect(requestQueueState.errorRequests.length).toBe(0); expect(wrapper.get("[data-testid='request-queue-errors-box']").text()).toContain("No stored errors"); expect(requestQueueState.missingPermissions.length).toBe(1); await wrapper.get("[data-testid='request-queue-clear-permissions']").trigger("click"); await flushManyMicrotasks(); expect(requestQueueState.missingPermissions.length).toBe(0); expect(wrapper.get("[data-testid='request-queue-missing-permissions-box']").text()).toContain( "No missing permissions detected" ); }); it("stays visible after errors occur", async () => { __configureRequestQueueForTests({ errorHistoryLimit: 2 }); const wrapper = mount(RequestQueueProgress); const failed = enqueueRequest( async () => { throw { message: "Failure should keep panel visible", response: { status: 500, data: { detail: "boom" } }, }; }, { method: "DELETE", url: "/error-visible", requestData: { data: { id: 123 } }, } ).catch((error) => error); await failed; await flushManyMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(false); await triggerShiftTriplePress(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true); vi.advanceTimersByTime(30_000); await flushManyMicrotasks(); expect(wrapper.find("[data-testid='request-queue-progress']").exists()).toBe(true); await flushManyMicrotasks(); const errorsBox = wrapper.get("[data-testid='request-queue-errors-box']"); expect(errorsBox.text()).toContain("Errors"); expect(errorsBox.text()).toContain("/error-visible"); }); it("stores missing permissions when response payload is stringified JSON", async () => { __configureRequestQueueForTests({ missingPermissionsLimit: 5, retryByStatusCode: {}, }); const request = enqueueRequest( async () => { throw { message: "Request failed with status code 403", response: { status: 403, data: JSON.stringify({ success: false, data: { message: "Missing permission(s)", permissions: ["department_notification_sms_get"], }, meta: [], includes: [], }), }, }; }, { method: "GET", url: "/notification/sms", } ).catch((error) => error); await request; await flushManyMicrotasks(); expect(requestQueueState.missingPermissions.length).toBe(1); expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get"); }); it("stores component permissions with COMPONENT method and keeps them below API entries", async () => { const wrapper = mount(RequestQueueProgress); const apiRequest = enqueueRequest( async () => { throw { message: "Request failed with status code 403", response: { status: 403, data: { success: false, data: { message: "Missing permission(s)", permissions: ["department_notification_sms_get"], }, }, }, }; }, { method: "GET", url: "/notification/sms", } ).catch((error) => error); await apiRequest; reportComponentMissingPermission("goals_department_create", { source: "DepartmentGoals:CreateGoalButton", }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); const rows = wrapper .get("[data-testid='request-queue-missing-permissions-box']") .findAll(".request-queue-progress__list-item--permission") .map((item) => item.text()); expect(rows.length).toBe(2); expect(rows[0]).toContain("department_notification_sms_get"); expect(rows[0]).toContain("GET"); expect(rows[1]).toContain("goals_department_create"); expect(rows[1]).toContain("COMPONENT"); }); it("throttles repeated component permission reports", async () => { __configureRequestQueueForTests({ componentPermissionReportWindowMs: 5000, missingPermissionsLimit: 10, }); reportComponentMissingPermission("goals_department_create", { source: "DepartmentGoals:CreateGoalButton", }); reportComponentMissingPermission("goals_department_create", { source: "DepartmentGoals:CreateGoalButton", }); await flushManyMicrotasks(); expect(requestQueueState.missingPermissions.length).toBe(1); expect(requestQueueState.missingPermissions[0].count).toBe(1); expect(requestQueueState.missingPermissions[0].method).toBe("COMPONENT"); vi.advanceTimersByTime(5100); reportComponentMissingPermission("goals_department_create", { source: "DepartmentGoals:CreateGoalButton", }); await flushManyMicrotasks(); expect(requestQueueState.missingPermissions.length).toBe(1); expect(requestQueueState.missingPermissions[0].count).toBe(2); }); it("shows add button for missing permissions when superuser impersonates a user", async () => { localStorage.setItem("superuser_token", "su-token"); SessionUser.user.group_id.value = 19; const wrapper = mount(RequestQueueProgress); const request = enqueueRequest( async () => { throw { message: "Request failed with status code 403", response: { status: 403, data: { success: false, data: { message: "Missing permission(s)", permissions: ["department_notification_sms_get"], }, }, }, }; }, { method: "GET", url: "/notification/sms", } ).catch((error) => error); await request; await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); expect( wrapper.find("[data-testid='request-queue-permission-action-department_notification_sms_get-GET']").exists() ).toBe(true); }); it("shows add button for COMPONENT missing permissions when superuser impersonates a user", async () => { localStorage.setItem("superuser_token", "su-token"); SessionUser.user.group_id.value = 19; const wrapper = mount(RequestQueueProgress); reportComponentMissingPermission("goals_department_create", { source: "DepartmentGoals:CreateGoalButton", }); await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); expect( wrapper.find("[data-testid='request-queue-permission-action-goals_department_create-COMPONENT']").exists() ).toBe(true); }); it("adds missing permission with superuser token and shows disabled checkmark on success", async () => { localStorage.setItem("superuser_token", "su-token"); SessionUser.user.group_id.value = 19; const axiosPostSpy = vi.spyOn(axios, "post").mockResolvedValue({ status: 200, data: {} }); const wrapper = mount(RequestQueueProgress); const request = enqueueRequest( async () => { throw { message: "Request failed with status code 403", response: { status: 403, data: { success: false, data: { message: "Missing permission(s)", permissions: ["department_notification_sms_get"], }, }, }, }; }, { method: "GET", url: "/notification/sms", } ).catch((error) => error); await request; await flushManyMicrotasks(); await triggerShiftTriplePress(); await flushManyMicrotasks(); const actionButton = wrapper.get( "[data-testid='request-queue-permission-action-department_notification_sms_get-GET']" ); await actionButton.trigger("click"); await flushManyMicrotasks(); expect(axiosPostSpy).toHaveBeenCalledWith( `${API_URL}/roles/permissions`, { group_id: 19, permission_id: "department_notification_sms_get", }, { headers: { Authorization: "Bearer su-token", }, } ); expect(actionButton.attributes("disabled")).toBeDefined(); expect(actionButton.find(".fa-check").exists()).toBe(true); axiosPostSpy.mockRestore(); }); });