- 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.
542 lines
17 KiB
JavaScript
542 lines
17 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
|
|
|
function json(body, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function matchesApiPath(urlString, expectedPath) {
|
|
const url = new URL(urlString);
|
|
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
|
|
}
|
|
|
|
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 observer = new MutationObserver(apply);
|
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
});
|
|
}
|
|
|
|
function createPeriodPayload() {
|
|
return {
|
|
types: {
|
|
all: [
|
|
{
|
|
id: 11,
|
|
customer_number: 4001,
|
|
customer_name: "Acme Fleet",
|
|
requires_action: true,
|
|
transactions: [
|
|
{ id: 9001, date: "2026-03-10T10:00:00.000Z", amount: 120, booked: false, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {
|
|
fixed_pricing: {
|
|
price: 500,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 12,
|
|
customer_number: 4002,
|
|
customer_name: "Nordic Transport",
|
|
requires_action: false,
|
|
transactions: [
|
|
{ id: 9002, date: "2026-03-11T10:00:00.000Z", amount: 80, booked: true, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
invoice_per_order: [
|
|
{
|
|
id: 13,
|
|
customer_number: 7001,
|
|
customer_name: "Invoice Per Order Co",
|
|
requires_action: true,
|
|
transactions: [
|
|
{ id: 9003, date: "2026-03-12T10:00:00.000Z", amount: 75, booked: false, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
fixed_pricing: [
|
|
{
|
|
id: 11,
|
|
customer_number: 4001,
|
|
customer_name: "Acme Fleet",
|
|
requires_action: true,
|
|
transactions: [],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {
|
|
fixed_pricing: {
|
|
price: 500,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
tank_cleaning: [],
|
|
special_arrangements: [],
|
|
vehicle_subscriptions: [
|
|
{
|
|
id: 14,
|
|
customer_number: 8001,
|
|
customer_name: "Subscription Movers",
|
|
requires_action: true,
|
|
transactions: [
|
|
{ id: 9004, date: "2026-03-13T10:00:00.000Z", amount: 60, booked: false, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
possible_duplicates: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function createPeriodPayloadForChangedRange() {
|
|
return {
|
|
types: {
|
|
all: [
|
|
{
|
|
id: 21,
|
|
customer_number: 5001,
|
|
customer_name: "April Logistics",
|
|
requires_action: true,
|
|
transactions: [
|
|
{ id: 9101, date: "2026-04-10T10:00:00.000Z", amount: 250, booked: false, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
invoice_per_order: [
|
|
{
|
|
id: 22,
|
|
customer_number: 5002,
|
|
customer_name: "April Per Order",
|
|
requires_action: true,
|
|
transactions: [
|
|
{ id: 9102, date: "2026-04-11T11:00:00.000Z", amount: 150, booked: false, excluded: false },
|
|
],
|
|
queue: {
|
|
has_active_job: false,
|
|
statuses: [],
|
|
invoice_collection_ids: [],
|
|
is_action_blocked: false,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
fixed_pricing: [],
|
|
tank_cleaning: [],
|
|
special_arrangements: [],
|
|
vehicle_subscriptions: [],
|
|
possible_duplicates: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function createQueuedPeriodPayload() {
|
|
return {
|
|
types: {
|
|
all: [
|
|
{
|
|
id: 31,
|
|
customer_number: 6001,
|
|
customer_name: "Queued Fleet",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 9201,
|
|
date: "2026-04-14T10:00:00.000Z",
|
|
amount: 210,
|
|
booked: false,
|
|
excluded: false,
|
|
invoice_collection_id: 14578,
|
|
queue_status: "QUEUED",
|
|
queue_job_id: 88,
|
|
},
|
|
],
|
|
queue: {
|
|
has_active_job: true,
|
|
statuses: ["QUEUED"],
|
|
invoice_collection_ids: [14578],
|
|
is_action_blocked: true,
|
|
},
|
|
meta: {},
|
|
},
|
|
],
|
|
invoice_per_order: [],
|
|
fixed_pricing: [],
|
|
tank_cleaning: [],
|
|
special_arrangements: [],
|
|
vehicle_subscriptions: [],
|
|
possible_duplicates: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
async function setupPeriodEndpoints(page, requests) {
|
|
let initialRange = null;
|
|
|
|
await page.route("**/superuser/invoicing/period**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(route.request().url());
|
|
if (!matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
const dateFrom = url.searchParams.get("dateFrom");
|
|
const dateTo = url.searchParams.get("dateTo");
|
|
requests.push({
|
|
dateFrom,
|
|
dateTo,
|
|
});
|
|
|
|
if (!initialRange) {
|
|
initialRange = { dateFrom, dateTo };
|
|
}
|
|
|
|
const didChangeRange = !initialRange
|
|
|| dateFrom !== initialRange.dateFrom
|
|
|| dateTo !== initialRange.dateTo;
|
|
|
|
await route.fulfill(json({
|
|
data: didChangeRange ? createPeriodPayloadForChangedRange() : createPeriodPayload(),
|
|
}));
|
|
});
|
|
|
|
await page.route("**/superuser/invoicing/period/distribution/fixed-pricing**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({
|
|
includes: {
|
|
collective_fixed_pricing_results: {
|
|
total_fixed_price: 500,
|
|
total_original_price: 400,
|
|
total_department_totals: {
|
|
1: 220,
|
|
2: 180,
|
|
},
|
|
total_department_totals_parsed: {
|
|
Copenhagen: 220,
|
|
Odense: 180,
|
|
},
|
|
total_department_totals_relative_parsed: {
|
|
Copenhagen: 260,
|
|
Odense: 240,
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
await page.route("**/superuser/invoicing/period/distribution/wash-subscriptions**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({
|
|
includes: {
|
|
collective_subscription_results: {
|
|
total_subscription_price: 300,
|
|
total_fixed_price: 300,
|
|
total_original_price: 300,
|
|
total_department_totals: {
|
|
1: 200,
|
|
2: 100,
|
|
},
|
|
subscription_price_department_distribution_parsed: {
|
|
Copenhagen: 200,
|
|
Odense: 100,
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
});
|
|
|
|
await page.route("**/departments**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(route.request().url());
|
|
if (!(url.pathname === "/departments" || url.pathname === "/api/departments")) {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({
|
|
data: [
|
|
{ id: 1, name: "Copenhagen" },
|
|
{ id: 2, name: "Odense" },
|
|
],
|
|
}));
|
|
});
|
|
}
|
|
|
|
async function openPeriodView(page) {
|
|
const periodRequests = [];
|
|
const token = "superuser-period-e2e-token";
|
|
await suppressVueDevtoolsOverlay(page);
|
|
await seedAuthenticatedState(page, token);
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: ["superuser", "user"],
|
|
loginToken: token,
|
|
invoiceDistribution: true,
|
|
});
|
|
await setupPeriodEndpoints(page, periodRequests);
|
|
await page.goto("/superuser/invoices?activeTab=period");
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
await expect(page.getByTestId("invoicing-period-view")).toBeVisible();
|
|
return { periodRequests };
|
|
}
|
|
|
|
async function seedInvoicesPage(page, token = "superuser-period-e2e-token") {
|
|
await suppressVueDevtoolsOverlay(page);
|
|
await seedAuthenticatedState(page, token);
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: ["superuser", "user"],
|
|
loginToken: token,
|
|
invoiceDistribution: true,
|
|
});
|
|
}
|
|
|
|
test.describe("Invoicing period tab", () => {
|
|
test("@smoke period view does not throw queue refresh errors on load", async ({ page }) => {
|
|
const pageErrors = [];
|
|
page.on("pageerror", (error) => {
|
|
pageErrors.push(error.message);
|
|
});
|
|
|
|
await openPeriodView(page);
|
|
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
|
|
await page.waitForTimeout(250);
|
|
|
|
expect(pageErrors).not.toEqual(expect.arrayContaining([
|
|
expect.stringContaining("reading 'value'"),
|
|
]));
|
|
});
|
|
|
|
test("@smoke period view loads selectors and displays all-customer list", async ({ page }) => {
|
|
const { periodRequests } = await openPeriodView(page);
|
|
|
|
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-view-selector-invoice_per_order")).toBeVisible();
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
|
|
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
|
|
expect(periodRequests.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
|
|
await openPeriodView(page);
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-invoice_per_order").click();
|
|
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "invoice_per_order");
|
|
await expect(page.getByTestId("invoicing-period-customer-7001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-4001")).toHaveCount(0);
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
});
|
|
|
|
test("@smoke period view shows invoice action only for customers requiring action", async ({ page }) => {
|
|
await openPeriodView(page);
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-invoice-4002")).toHaveCount(0);
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
});
|
|
|
|
test("@smoke period view date changes trigger a refreshed period query", async ({ page }) => {
|
|
const { periodRequests } = await openPeriodView(page);
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
|
|
|
|
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
|
await expect(dateInputs.first()).toBeVisible();
|
|
|
|
await dateInputs.nth(0).fill("2026-04-02");
|
|
await dateInputs.nth(0).dispatchEvent("change");
|
|
await dateInputs.nth(1).fill("2026-04-30");
|
|
await dateInputs.nth(1).dispatchEvent("change");
|
|
|
|
const firstRange = periodRequests[0];
|
|
await expect.poll(() => periodRequests.some((request) => (
|
|
request.dateFrom !== firstRange?.dateFrom || request.dateTo !== firstRange?.dateTo
|
|
))).toBeTruthy();
|
|
await expect(page.getByTestId("invoicing-period-customer-5001")).toBeVisible();
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
});
|
|
|
|
test("@smoke period view reload button triggers a fresh period query", async ({ page }, testInfo) => {
|
|
test.skip(/mobile/i.test(testInfo.project.name), "Reload button is not rendered in mobile date selector layout.");
|
|
|
|
const pageErrors = [];
|
|
page.on("pageerror", (error) => {
|
|
pageErrors.push(error.message);
|
|
});
|
|
|
|
const { periodRequests } = await openPeriodView(page);
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
const initialRequestCount = periodRequests.length;
|
|
await page.getByTestId("invoicing-period-reload-button").click();
|
|
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
|
|
await page.waitForTimeout(250);
|
|
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
expect(pageErrors).not.toEqual(expect.arrayContaining([
|
|
expect.stringContaining("reading 'types'"),
|
|
]));
|
|
});
|
|
|
|
test("@smoke period view shows a queued CTA when backend queue metadata blocks invoicing", async ({ page }) => {
|
|
const periodRequests = [];
|
|
await seedInvoicesPage(page);
|
|
await setupPeriodEndpoints(page, periodRequests);
|
|
|
|
await page.route("**/superuser/invoicing/period**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(route.request().url());
|
|
periodRequests.push({
|
|
dateFrom: url.searchParams.get("dateFrom"),
|
|
dateTo: url.searchParams.get("dateTo"),
|
|
});
|
|
|
|
await route.fulfill(json({
|
|
data: createQueuedPeriodPayload(),
|
|
}));
|
|
});
|
|
|
|
await page.goto("/superuser/invoices?activeTab=period");
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
await expect(page.getByTestId("invoicing-period-view")).toBeVisible();
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
await expect(page.getByTestId("invoicing-period-customer-6001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
|
|
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeDisabled();
|
|
await expect(page.getByTestId("invoicing-period-customer-invoice-6001")).toHaveCount(0);
|
|
expect(periodRequests.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("@smoke period view refreshes queue state when the Period route becomes active again", async ({ page }) => {
|
|
const periodRequests = [];
|
|
let requestCount = 0;
|
|
let returnQueuedPayload = false;
|
|
|
|
await seedInvoicesPage(page);
|
|
await setupPeriodEndpoints(page, periodRequests);
|
|
|
|
await page.route("**/superuser/invoicing/period**", async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
requestCount += 1;
|
|
const url = new URL(route.request().url());
|
|
periodRequests.push({
|
|
dateFrom: url.searchParams.get("dateFrom"),
|
|
dateTo: url.searchParams.get("dateTo"),
|
|
});
|
|
|
|
await route.fulfill(json({
|
|
data: returnQueuedPayload ? createQueuedPeriodPayload() : createPeriodPayload(),
|
|
}));
|
|
});
|
|
|
|
await page.goto("/superuser/invoices?activeTab=overview");
|
|
await expect(page).toHaveURL(/activeTab=overview/);
|
|
returnQueuedPayload = true;
|
|
|
|
await page.goto("/superuser/invoices?activeTab=period");
|
|
await expect(page).toHaveURL(/activeTab=period/);
|
|
await expect.poll(() => requestCount).toBeGreaterThan(0);
|
|
|
|
await page.getByTestId("invoicing-period-view-selector-all").click();
|
|
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
|
|
});
|
|
});
|