import { expect, test } from "@playwright/test"; import { mockApi, seedAuthenticatedState } from "./support/network.js"; const QUEUE_LIST_PATH = "/collected-invoices/economic/queue"; const QUEUE_STATUS_PATH = "/collected-invoices/economic/queue/status"; const QUEUE_RUN_PATH = "/collected-invoices/economic/queue/run"; const QUEUE_RETRY_PATH = "/collected-invoices/economic/queue/retry"; function json(body, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } function isQueuePath(requestUrl, path) { const url = new URL(requestUrl); return url.pathname === path || url.pathname === `/api${path}`; } async function suppressVueDevtoolsOverlay(page) { await page.addInitScript(() => { const STYLE_ID = "__e2e-hide-vue-devtools"; const apply = () => { if (!document.getElementById(STYLE_ID)) { const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }"; (document.head || document.documentElement).appendChild(style); } const container = document.getElementById("__vue-devtools-container__"); if (container) { container.style.display = "none"; container.style.pointerEvents = "none"; } }; apply(); const observer = new MutationObserver(apply); observer.observe(document.documentElement, { childList: true, subtree: true }); }); } async function bootstrapAuthenticatedSuperuser(page) { const token = "superuser-queue-history-e2e-token"; await suppressVueDevtoolsOverlay(page); await seedAuthenticatedState(page, token); await mockApi(page, { authenticated: true, permissions: ["superuser", "user"], loginToken: token, }); return token; } async function openQueueHistory(page, token) { await page.goto("/"); await page.evaluate((value) => { window.localStorage.setItem("token", value); }, token); await page.goto("/superuser/invoices?activeTab=queue"); await expect(page).toHaveURL(/activeTab=queue/); await expect(page.getByTestId("economic-queue-history-page")).toBeVisible(); } test.describe("Invoice transfer queue history reliability", () => { test("@smoke polling progression reaches terminal state and stops", async ({ page }) => { let queueListCalls = 0; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } queueListCalls += 1; const isTerminal = queueListCalls > 1; await route.fulfill( json({ data: { items: [ { id: 9301, status: isTerminal ? "COMPLETED" : "PROCESSING", progress_percent: isTerminal ? 100 : 45, progress_message: isTerminal ? "Completed" : "Exporting line items", collected_invoice_id: 7001, attempts: 1, max_attempts: 3, created_at: "2026-04-08T10:00:00Z", updated_at: "2026-04-08T10:00:00Z", }, ], count: 1, total: 1, limit: 50, offset: 0, has_more: false, }, }) ); }); await openQueueHistory(page, token); await expect(page.getByTestId("economic-queue-history-status-9301")).toContainText("PROCESSING"); await expect .poll(async () => page.getByTestId("economic-queue-history-status-9301").innerText(), { timeout: 12_000, }) .toContain("COMPLETED"); const callsAtTerminal = queueListCalls; await page.waitForTimeout(5_000); expect(queueListCalls).toBe(callsAtTerminal); }); test("@smoke status/limit/offset filters follow server metadata pagination", async ({ page }) => { const queueRequests = []; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } const url = new URL(route.request().url()); const status = url.searchParams.get("status"); const limit = Number(url.searchParams.get("limit") || "50"); const offset = Number(url.searchParams.get("offset") || "0"); queueRequests.push({ status, limit, offset }); const hasMore = offset < 10; await route.fulfill( json({ data: { items: [ { id: hasMore ? 9501 : 9502, status: "FAILED", progress_percent: hasMore ? 20 : 100, progress_message: hasMore ? "Failed" : "Failed and exhausted", error_message: hasMore ? "Transient API timeout" : "Permanent validation failure", collected_invoice_id: hasMore ? 7301 : 7302, attempts: hasMore ? 1 : 3, max_attempts: 3, created_at: "2026-04-08T11:00:00Z", updated_at: "2026-04-08T11:00:30Z", }, ], count: 1, total: 11, limit, offset, has_more: hasMore, }, }) ); }); await openQueueHistory(page, token); await page.getByTestId("economic-queue-history-status-filter").selectOption("FAILED"); await page.getByTestId("economic-queue-history-limit-filter").selectOption("10"); await expect .poll( () => queueRequests.some((request) => request.status === "FAILED" && request.limit === 10 && request.offset === 0), { timeout: 8_000 } ) .toBeTruthy(); await expect(page.getByTestId("economic-queue-history-summary")).toContainText("More results available."); await page.getByTestId("economic-queue-history-next-page").click(); await expect .poll( () => queueRequests.some((request) => request.status === "FAILED" && request.limit === 10 && request.offset === 10), { timeout: 8_000 } ) .toBeTruthy(); await expect(page.getByTestId("economic-queue-history-summary")).not.toContainText("More results available."); await expect(page.getByTestId("economic-queue-history-next-page")).toBeDisabled(); }); test("@smoke run queue now falls back when the backend route is ahead of the queue class deployment", async ({ page, }) => { let statusCalls = 0; let queueListCalls = 0; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue/run**", async (route) => { if (route.request().method() !== "POST" || !isQueuePath(route.request().url(), QUEUE_RUN_PATH)) { await route.fallback(); return; } await route.fulfill( json( { message: "Internal server error: Call to undefined method classes\\economic_transfer_queue::processPendingByTransferType()", }, 500 ) ); }); await page.route("**/collected-invoices/economic/queue/status**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_STATUS_PATH)) { await route.fallback(); return; } statusCalls += 1; const url = new URL(route.request().url()); expect(url.searchParams.get("job_id")).toBe("9351"); await route.fulfill( json({ data: { id: 9351, status: "PROCESSING", progress_percent: 45, progress_message: "Legacy worker tick", }, }) ); }); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } queueListCalls += 1; await route.fulfill( json({ data: { items: [ { id: 9351, status: queueListCalls === 1 ? "QUEUED" : "PROCESSING", progress_percent: queueListCalls === 1 ? 0 : 45, progress_message: queueListCalls === 1 ? "Queued" : "Legacy worker tick", collected_invoice_id: 7351, attempts: 0, max_attempts: 3, created_at: "2026-04-08T11:30:00Z", updated_at: "2026-04-08T11:30:30Z", }, ], count: 1, total: 1, limit: 50, offset: 0, has_more: false, }, }) ); }); await openQueueHistory(page, token); await page.getByTestId("economic-queue-history-run-now").click(); await expect.poll(() => statusCalls, { timeout: 8_000 }).toBe(1); await expect(page.getByTestId("economic-queue-history-run-summary")).toContainText("Legacy fallback active"); await expect(page.getByTestId("economic-queue-history-summary")).toContainText( "Legacy fallback active while the backend queue runner is unavailable." ); await expect(page.getByTestId("economic-queue-history-error")).toHaveCount(0); await expect(page.getByTestId("economic-queue-history-status-9351")).toContainText("PROCESSING"); }); test("@smoke failed-job retry succeeds and non-failed retry stays unavailable", async ({ page }) => { let retryCalls = 0; let retryTriggered = false; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue/retry**", async (route) => { if (route.request().method() !== "POST" || !isQueuePath(route.request().url(), QUEUE_RETRY_PATH)) { await route.fallback(); return; } retryCalls += 1; expect(route.request().postDataJSON()).toEqual({ job_id: 9402 }); retryTriggered = true; await route.fulfill( json({ data: { job: { id: 9402, status: "QUEUED", progress_percent: 0, progress_message: "Retry queued", }, }, }) ); }); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } await route.fulfill( json({ data: { items: [ { id: 9401, status: "COMPLETED", progress_percent: 100, progress_message: "Completed", collected_invoice_id: 7401, attempts: 1, max_attempts: 3, created_at: "2026-04-08T12:00:00Z", updated_at: "2026-04-08T12:01:00Z", }, { id: 9402, status: retryTriggered ? "QUEUED" : "FAILED", progress_percent: retryTriggered ? 0 : 40, progress_message: retryTriggered ? "Retry queued" : "Failed", error_message: retryTriggered ? "" : "Temporary upstream issue", collected_invoice_id: 7402, attempts: retryTriggered ? 1 : 1, max_attempts: 3, created_at: "2026-04-08T12:00:00Z", updated_at: "2026-04-08T12:01:00Z", }, { id: 9403, status: "FAILED", progress_percent: 100, progress_message: "Failed", error_message: "Max attempts reached", collected_invoice_id: 7403, attempts: 3, max_attempts: 3, created_at: "2026-04-08T12:00:00Z", updated_at: "2026-04-08T12:01:00Z", }, ], count: 3, total: 3, limit: 50, offset: 0, has_more: false, }, }) ); }); await openQueueHistory(page, token); await expect(page.getByTestId("economic-queue-history-retry-9401")).toHaveCount(0); await expect(page.getByTestId("economic-queue-history-retry-9403")).toBeDisabled(); await page.getByTestId("economic-queue-history-retry-9402").click(); await expect.poll(() => retryCalls, { timeout: 8_000 }).toBe(1); await expect(page.getByTestId("economic-queue-history-status-9402")).toContainText("QUEUED"); await expect(page.getByTestId("economic-queue-history-retry-9402")).toHaveCount(0); }); test("@smoke polling transport error is surfaced and recovery succeeds", async ({ page }) => { let queueListCalls = 0; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } queueListCalls += 1; if (queueListCalls >= 2 && queueListCalls <= 4) { await route.fulfill( json( { data: { message: "gateway timeout", }, }, 503 ) ); return; } const isTerminal = queueListCalls >= 6; await route.fulfill( json({ data: { items: [ { id: 9601, status: isTerminal ? "COMPLETED" : "PROCESSING", progress_percent: isTerminal ? 100 : 60, progress_message: isTerminal ? "Completed" : "Retrying worker connection", collected_invoice_id: 7601, attempts: 1, max_attempts: 3, created_at: "2026-04-08T13:00:00Z", updated_at: "2026-04-08T13:00:30Z", }, ], count: 1, total: 1, limit: 50, offset: 0, has_more: false, }, }) ); }); await openQueueHistory(page, token); await expect(page.getByTestId("economic-queue-history-status-9601")).toContainText("PROCESSING"); await expect(page.getByTestId("economic-queue-history-poll-error")).toContainText("gateway timeout", { timeout: 12_000, }); await page.getByTestId("economic-queue-history-refresh").click(); await expect(page.getByTestId("economic-queue-history-poll-error")).toHaveCount(0); await expect .poll(async () => page.getByTestId("economic-queue-history-status-9601").innerText(), { timeout: 12_000, }) .toContain("COMPLETED"); await expect(page.getByTestId("economic-queue-history-error")).toHaveCount(0); }); test("@smoke slow responses do not create overlapping polling requests", async ({ page }) => { let queueListCalls = 0; let inFlight = 0; let maxInFlight = 0; const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); queueListCalls += 1; const callNumber = queueListCalls; try { await new Promise((resolve) => setTimeout(resolve, 700)); const isTerminal = callNumber >= 3; await route.fulfill( json({ data: { items: [ { id: 9701, status: isTerminal ? "COMPLETED" : "PROCESSING", progress_percent: isTerminal ? 100 : 55, progress_message: isTerminal ? "Completed" : "Working", collected_invoice_id: 7701, attempts: 1, max_attempts: 3, created_at: "2026-04-08T14:00:00Z", updated_at: "2026-04-08T14:00:30Z", }, ], count: 1, total: 1, limit: 50, offset: 0, has_more: false, }, }) ); } finally { inFlight -= 1; } }); await openQueueHistory(page, token); await expect.poll(() => queueListCalls, { timeout: 20_000 }).toBeGreaterThanOrEqual(3); expect(maxInFlight).toBe(1); await expect(page.getByTestId("economic-queue-history-status-9701")).toContainText("COMPLETED"); }); test("@smoke mobile layout parity keeps queue controls and actions accessible", async ({ page }, testInfo) => { test.skip(!testInfo.project.name.includes("mobile"), "Mobile-specific layout assertions"); const token = await bootstrapAuthenticatedSuperuser(page); await page.route("**/collected-invoices/economic/queue**", async (route) => { if (route.request().method() !== "GET" || !isQueuePath(route.request().url(), QUEUE_LIST_PATH)) { await route.fallback(); return; } await route.fulfill( json({ data: { items: [ { id: 9801, status: "FAILED", progress_percent: 100, progress_message: "Failed", error_message: "Retry available", collected_invoice_id: 7801, attempts: 1, max_attempts: 3, created_at: "2026-04-08T15:00:00Z", updated_at: "2026-04-08T15:00:30Z", details_summary: { message: "Queue failure details", target: { collected_invoice_id: 7801, send_as_is: false, requested_by: 7, }, customer: { customer_number: 43425425, name: "Carrier A/S", }, outcome: { status: "FAILED", error_message: "Retry available", economic_invoice_draft_id: null, economic_invoice_booked_id: null, external_id: null, total_net_amount: null, order_count: 0, }, transfer: { transfer_type: "COLLECTED_INVOICE_EXPORT", status: "FAILED", progress_percent: 100, progress_message: "Failed", attempts: 1, max_attempts: 3, }, technical: { job_id: 9801, created_by: 7, created_at: "2026-04-08T15:00:00Z", updated_at: "2026-04-08T15:00:30Z", raw_available: { payload: true, result: true, }, raw: { payload: { collected_invoice_id: 7801, }, result: null, }, }, raw_available: { payload: true, result: true, }, }, }, ], count: 1, total: 1, limit: 50, offset: 0, has_more: false, }, }) ); }); await openQueueHistory(page, token); await expect(page.getByTestId("economic-queue-history-run-now")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-refresh")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-prev-page")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-next-page")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-retry-9801")).toBeVisible(); await page.getByTestId("economic-queue-history-details-9801").click(); await expect(page.getByTestId("economic-queue-history-details-modal")).toBeVisible(); await expect(page.getByTestId("economic-queue-history-details-section-overfoersel")).toContainText("FAILED"); await page.getByTestId("economic-queue-history-details-close").click(); await expect(page.getByTestId("economic-queue-history-details-modal")).toHaveCount(0); }); });