import { expect, test } from "@playwright/test"; import { mockApi, seedAuthenticatedState } from "./support/network.js"; const API_HOST = /https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i; function json(body, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } function suppressVueDevtoolsOverlay(page) { return page.addInitScript(() => { const style = document.createElement("style"); style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }"; document.documentElement.appendChild(style); }); } async function primeSession(page, { token, permissions, sessionData = {} }) { await seedAuthenticatedState(page, token); await page.goto("/login"); await page.evaluate( async ({ sessionToken, sessionPermissions, data }) => { const sessionModule = await import("/src/components/session/token/SessionUser.vue"); window.localStorage.setItem("token", sessionToken); sessionModule.SessionUser.token.value = sessionToken; sessionModule.SessionUser.authenticated.value = true; sessionModule.SessionUser.permissions.value = sessionPermissions; sessionModule.SessionUser.initiated.value = true; sessionModule.SessionUser.user.customer_number.value = data.customer_number ?? 12345; sessionModule.SessionUser.user.display_name.value = data.display_name ?? "POS E2E"; }, { sessionToken: token, sessionPermissions: permissions, data: sessionData, } ); } async function expectDesktopOrderItemsTable(scope) { await expect(scope.getByTestId("pos-order-panel-cart")).toBeVisible(); await expect(scope.getByTestId("pos-order-detail-items-table")).toBeVisible(); await expect(scope.getByTestId("pos-order-metadata-grid")).toBeVisible(); await expect(scope.getByTestId("pos-order-total")).toBeVisible(); await expect(scope.locator('[data-auto-excel-export-button="1"]')).toHaveCount(0); } function createPosFixture() { const customer = { id: 1, customerNumber: 12345, name: "Pleno Logistics", address: "Demo Street 1", zip: "2630", city: "Taastrup", mobilePhone: "12345678", email: "pos-e2e@example.com", corporateIdentificationNumber: "12345678", barred: false, }; const products = [ { id: 53, name: "Tankvogn med hænger", description: "Material product used for completion flow", price: 599, subscription_allowed: true, category: 4, piktogram: "truck", apply_category_discount: true, requires_note: false, is_wash: true, display_in_booking_form: true, order_priority: 1, addons: [], }, { id: 63, name: "Dolly", description: "Support product", price: 275, subscription_allowed: true, category: 4, piktogram: "truck", apply_category_discount: true, requires_note: false, is_wash: true, display_in_booking_form: true, order_priority: 2, addons: [], }, ]; const ordersById = { 9201: { id: 9201, customer_id: customer.customerNumber, department_id: 1, reference: "REF-9201", notes: "", reg_1: "AB12345", reg_2: "", reg_3: "", invoice_collection_id: null, booking_id: null, completed_at: null, created_at: "2026-01-01T10:00:00.000Z", }, }; const orderItemsByOrderId = { 9201: [], }; return { customer, products, departmentCategories: [ { id: 11, department_id: 1, category: { id: 4, name: "Udvendig", meta: { products: [53, 63], }, }, }, ], vehicles: [ { id: 7001, reg: "AB12345", customer_id: customer.customerNumber, customer_name: customer.name, type: 53, status: "verified", barred: false, wash_subscription: false, addons: { enabled: 0, available: 0, list: [], }, reference: "REF-AB12345", last_order_id: null, }, ], unknownVehicles: [], ordersById, orderItemsByOrderId, markCompletedOrderIds: [], nextOrderId: 9300, nextOrderItemId: 9800, }; } function buildOrderItem(product, body, id) { const quantity = Number(body.quantity || 1); return { id, order_id: Number(body.order_id), product_id: product.id, product, quantity, notes: body.notes || "", reference: body.reference || "", related_item_id: body.related_item_id ?? null, price: Number(body.price ?? product.price ?? 0), }; } async function mockPosApi(page, fixture) { await page.route(API_HOST, async (route) => { const request = route.request(); const url = request.url(); const parsedUrl = new URL(url); const pathname = parsedUrl.pathname; const method = request.method(); if (pathname.endsWith("/departments") && method === "GET") { await route.fulfill( json({ success: true, data: [ { id: 1, name: "Taastrup", }, ], }) ); return; } if (pathname.endsWith("/departments/categories") && method === "GET") { await route.fulfill( json({ success: true, data: fixture.departmentCategories, }) ); return; } if (pathname.endsWith("/bookings") && method === "GET") { await route.fulfill( json({ success: true, data: [], }) ); return; } if (pathname.endsWith("/vehicles") && method === "GET") { const search = (parsedUrl.searchParams.get("search") || "").toUpperCase(); const matches = fixture.vehicles.filter((vehicle) => vehicle.reg.includes(search)); await route.fulfill( json({ success: true, data: matches, }) ); return; } if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") { await route.fulfill( json({ success: true, data: fixture.unknownVehicles, }) ); return; } if (pathname.endsWith("/users/customer") && method === "GET") { await route.fulfill( json({ success: true, data: { customer_name: fixture.customer.name, economic_customer: fixture.customer, }, }) ); return; } if (pathname.endsWith("/customers") && method === "GET") { await route.fulfill( json({ success: true, data: [fixture.customer], meta: { pagination: { page: 1, limit: 10, total: 1, }, }, }) ); return; } if (pathname.endsWith("/customer/notes") && method === "GET") { await route.fulfill( json({ success: true, data: [], }) ); return; } if (pathname.endsWith("/customer/attributes") && method === "GET") { await route.fulfill( json({ success: true, data: [], }) ); return; } if (pathname.endsWith("/superuser/user/discounts") && method === "GET") { await route.fulfill( json({ success: true, data: [], }) ); return; } if (pathname.endsWith("/products") && method === "GET") { const id = parsedUrl.searchParams.get("id"); const category = parsedUrl.searchParams.get("category"); if (id) { const product = fixture.products.find((entry) => Number(entry.id) === Number(id)); await route.fulfill( json({ success: true, data: product || null, }) ); return; } const list = category ? fixture.products.filter((entry) => Number(entry.category) === Number(category)) : fixture.products; await route.fulfill( json({ success: true, data: list, }) ); return; } if (pathname.endsWith("/orders") && method === "GET") { await route.fulfill( json({ success: true, data: [], }) ); return; } if (pathname.endsWith("/orders") && method === "POST") { const body = request.postDataJSON?.() || {}; const orderId = fixture.nextOrderId++; fixture.ordersById[orderId] = { id: orderId, customer_id: Number(body.customer_id), department_id: Number(body.department_id), reference: body.reference || "", notes: body.notes || "", reg_1: body.reg_1 || "", reg_2: body.reg_2 || "", reg_3: body.reg_3 || "", invoice_collection_id: null, booking_id: null, completed_at: null, created_at: new Date().toISOString(), }; fixture.orderItemsByOrderId[orderId] = []; await route.fulfill( json({ success: true, data: { id: orderId, }, }) ); return; } if (pathname.endsWith("/order") && method === "GET") { const id = Number(parsedUrl.searchParams.get("id")); await route.fulfill( json({ success: true, data: fixture.ordersById[id] || null, }) ); return; } if (pathname.endsWith("/order") && method === "PUT") { const body = request.postDataJSON?.() || {}; const orderId = Number(body.id); if (fixture.ordersById[orderId]) { if (body.field) { fixture.ordersById[orderId][body.field] = body.value; } else { fixture.ordersById[orderId] = { ...fixture.ordersById[orderId], ...body, }; } } await route.fulfill( json({ success: true, data: fixture.ordersById[orderId] || null, }) ); return; } if (pathname.endsWith("/order/items") && method === "GET") { const orderId = Number(parsedUrl.searchParams.get("order_id")); await route.fulfill( json({ success: true, data: fixture.orderItemsByOrderId[orderId] || [], }) ); return; } if (pathname.endsWith("/order/items") && method === "POST") { const body = request.postDataJSON?.() || {}; const orderId = Number(body.order_id); const productId = Number(body.product_id); const product = fixture.products.find((entry) => Number(entry.id) === productId); if (!product || !fixture.ordersById[orderId]) { await route.fulfill( json( { success: false, data: { message: "Order or product not found", }, }, 422 ) ); return; } const item = buildOrderItem(product, body, fixture.nextOrderItemId++); if (!Array.isArray(fixture.orderItemsByOrderId[orderId])) { fixture.orderItemsByOrderId[orderId] = []; } fixture.orderItemsByOrderId[orderId].push(item); await route.fulfill( json({ success: true, data: item, }) ); return; } if (pathname.endsWith("/order/items") && method === "PUT") { const body = request.postDataJSON?.() || {}; const orderItemId = Number(body.id); Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => { fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).map((item) => { if (item.id !== orderItemId) { return item; } return { ...item, price: Number(body.price ?? item.price), notes: body.notes ?? item.notes, reference: body.reference ?? item.reference, quantity: Number(body.quantity ?? item.quantity), }; }); }); await route.fulfill( json({ success: true, data: { id: orderItemId, }, }) ); return; } if (pathname.endsWith("/order/items") && method === "DELETE") { const orderItemId = Number(parsedUrl.searchParams.get("id")); Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => { fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).filter( (item) => item.id !== orderItemId ); }); await route.fulfill( json({ success: true, data: true, }) ); return; } if (pathname.endsWith("/departments/order/recommended") && method === "GET") { await route.fulfill( json({ success: true, data: { reg_1: { motorapi: [53], order_history: { 2: [], 3: [], 4: [], 5: [], }, }, }, }) ); return; } if (pathname.endsWith("/orders/mark_as_completed") && method === "POST") { const body = request.postDataJSON?.() || {}; const orderId = Number(body.id); fixture.markCompletedOrderIds.push(orderId); if (fixture.ordersById[orderId]) { fixture.ordersById[orderId].completed_at = new Date().toISOString(); } await route.fulfill( json({ success: true, data: { id: orderId, }, }) ); return; } await route.fallback(); }); } function seedMobilePosState(page) { const state = { vehicles: { vehicle_1: { reg: "AB12345", customer_id: 12345, type: 53, status: "verified", barred: false, booking_id: null, addons: [], }, vehicle_2: null, vehicle_3: null, activeVehicleIndex: 1, }, views: { manualInput: false, vehicleSelection: false, additionalItemSelection: false, transactionHistoryView: false, }, transactionItems: { primaryItem: { id: 53, name: "Tankvogn med hænger", description: "Material product used for completion flow", price: 599, category: 4, is_wash: true, addons: [], }, additionalItems: [], }, categories: { list: [], selected: null, }, productList: { list: [], }, metadata: { customerId: 12345, notes: "", reference: "REF-9201", washId: null, bookingId: null, laneId: null, }, attachments: { files: [], base64: [], wash_certificate: false, }, transactionHistory: [], lastVehicleOrders: { vehicle_1: null, vehicle_2: null, vehicle_3: null, }, timestamp: Date.now(), }; return page.addInitScript((payload) => { window.localStorage.setItem("pos", JSON.stringify(payload)); }, state); } test.describe("POS flow", () => { test.beforeEach(async ({ page }) => { await suppressVueDevtoolsOverlay(page); }); test("desktop bootstrap ignores customer_id-only query for order loading", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS bootstrap is validated on chromium-desktop."); const fixture = createPosFixture(); const permissions = ["admin", "department_access_1"]; const invalidOrderRequests = []; const reg1Errors = []; page.on("request", (request) => { const requestUrl = request.url(); if ( request.method() === "GET" && (requestUrl.includes("/order/items?order_id=null") || requestUrl.includes("/order/items?order_id=undefined") || requestUrl.includes("/order/items?order_id=NaN") || requestUrl.includes("/order?id=null") || requestUrl.includes("/order?id=undefined") || requestUrl.includes("/order?id=NaN")) ) { invalidOrderRequests.push(requestUrl); } }); page.on("console", (message) => { if (message.type() === "error" && message.text().includes("reg_1")) { reg1Errors.push(message.text()); } }); await mockApi(page, { authenticated: true, permissions, }); await mockPosApi(page, fixture); await primeSession(page, { token: "pos-bootstrap-token", permissions, sessionData: { display_name: "POS Bootstrap", }, }); await page.goto("/admin/1/modules/pos?customer_id=12345&step=1"); await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-step-1").getByText("Nummerplader").first()).toBeVisible({ timeout: 10_000 }); expect(invalidOrderRequests).toEqual([]); expect(reg1Errors).toEqual([]); }); test("desktop flow creates transaction, renders cart, and reaches completion step", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS flow is validated on chromium-desktop."); const fixture = createPosFixture(); const permissions = ["admin", "department_access_1"]; await mockApi(page, { authenticated: true, permissions, }); await mockPosApi(page, fixture); await primeSession(page, { token: "pos-desktop-token", permissions, sessionData: { display_name: "POS Desktop", }, }); await page.goto("/admin/1/modules/pos"); await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 }); await page.locator("#reg_1").fill("AB12345"); await expect(page.getByTestId("pos-step-1").getByText("Pleno Logistics").first()).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-step-1").locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde", { timeout: 10_000, }); await page.locator('[data-testid="pos-next-step"]:visible').click(); await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page).toHaveURL(/step=2/); await expectDesktopOrderItemsTable(page.getByTestId("pos-step-2")); await page.getByTestId("pos-product-card-53").click(); await expect(page.getByTestId("pos-add-to-cart-53")).toBeVisible({ timeout: 10_000 }); const addItemRequest = page.waitForRequest((request) => { return request.method() === "POST" && request.url().includes("/order/items"); }); await page.getByTestId("pos-add-to-cart-53").click(); await addItemRequest; await expect(page.getByTestId("pos-step-2")).toContainText("Tankvogn med hænger"); await page.locator('[data-testid="pos-next-step"]:visible').click(); await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 }); await expect(page).toHaveURL(/step=3/); await expectDesktopOrderItemsTable(page.getByTestId("pos-step-3")); const markCompletedRequest = page.waitForRequest((request) => { return request.method() === "POST" && request.url().includes("/orders/mark_as_completed"); }); await page.locator('[data-testid="pos-next-step"]:visible').click(); await markCompletedRequest; await expect .poll( async () => { if (await page.getByTestId("pos-step-4").isVisible()) { return "step4"; } if (/\/admin\/1\/modules\/pos(?:\?|$)/.test(page.url()) && !page.url().includes("step=3")) { return "reset"; } return "pending"; }, { timeout: 10_000 } ) .not.toBe("pending"); if (await page.getByTestId("pos-step-4").isVisible()) { await expectDesktopOrderItemsTable(page.getByTestId("pos-step-4")); } }); });