- Introduced e2e test for "Change Invoice Collection" in `change-invoice-collection.spec.ts`. - Added unit tests for `CollectedOrderInvoiceOverview.vue` including edit flow for `closed_at`. - Implemented tests for queue reliability, state handling, and customer actions: - `CollectedOrderInvoicesQueueHistory.vue`: polling, error handling, richer diagnostics, and retry logic. - `InvoicingBillingPeriod` views: refresh and queue state handling. - Enhanced test coverage for Stripe queue functionality and related actions.
567 lines
20 KiB
JavaScript
567 lines
20 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 = () => {
|
|
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);
|
|
});
|
|
});
|