Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
622 lines
21 KiB
JavaScript
622 lines
21 KiB
JavaScript
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 = () => {
|
|
const target = document.head || document.documentElement;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
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; }";
|
|
target.appendChild(style);
|
|
}
|
|
|
|
const container = document.getElementById("__vue-devtools-container__");
|
|
if (container) {
|
|
container.style.display = "none";
|
|
container.style.pointerEvents = "none";
|
|
}
|
|
};
|
|
|
|
apply();
|
|
const startObserving = () => {
|
|
if (!document.documentElement) {
|
|
requestAnimationFrame(startObserving);
|
|
return;
|
|
}
|
|
|
|
const observer = new MutationObserver(apply);
|
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
};
|
|
|
|
startObserving();
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
async function openQueueHistory(page) {
|
|
await page.goto("/superuser/invoices?activeTab=queue");
|
|
await expect(page).toHaveURL(/activeTab=queue/);
|
|
await expect(page.getByTestId("economic-queue-history-page")).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
test.describe("Invoice transfer queue history reliability", () => {
|
|
test("@smoke polling progression reaches terminal state and stops", async ({ page }) => {
|
|
let queueListCalls = 0;
|
|
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);
|
|
|
|
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 = [];
|
|
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);
|
|
|
|
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;
|
|
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);
|
|
|
|
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;
|
|
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);
|
|
|
|
await expect(page.getByTestId("economic-queue-history-retry-9401")).toHaveCount(0);
|
|
await expect(page.getByTestId("economic-queue-history-retry-9403")).toBeDisabled();
|
|
|
|
const retryButton = page.getByTestId("economic-queue-history-retry-9402");
|
|
await expect(retryButton).toBeEnabled();
|
|
await retryButton.click();
|
|
await expect.poll(() => retryCalls, { timeout: 15_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;
|
|
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);
|
|
|
|
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;
|
|
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);
|
|
|
|
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");
|
|
|
|
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);
|
|
|
|
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);
|
|
});
|
|
});
|