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(); }); });