import { expect, test } from "@playwright/test"; import { containsSuspiciousEncoding } from "../../scripts/text-encoding.mjs"; import { DEFAULT_BOOKING_ID, DEFAULT_DEPARTMENT_ID, DEFAULT_LAST_ORDER_ID, REGULAR_CUSTOMER_ID, buildMobilePosState, createAttachmentFile, createMobilePosFixture, getByActionKey, gotoMobilePos, getStoredPosSnapshot, setupMobilePosPage, suppressVueDevtoolsOverlay, waitForMobileNextStepCooldown, } from "./support/mobilePos.js"; const MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID = 44556677; const MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME = "Draft transaction customer"; function buildRegularOrder(orderId, overrides = {}) { return { id: orderId, customer_id: REGULAR_CUSTOMER_ID, department_id: DEFAULT_DEPARTMENT_ID, reference: "STEP2-REF", notes: "", po: "", 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", ...overrides, }; } function buildMobileOrderBooking(id, overrides = {}) { return { id, customer_number: REGULAR_CUSTOMER_ID, customer_name: "Pleno Logistics", department: DEFAULT_DEPARTMENT_ID, reg_1: "BOOK123", reg_2: "", reg_3: "", reference: `BOOKING-REF-${id}`, reference_number: `BOOKING-REF-${id}`, notes: `Booking notes ${id}`, note: `Booking notes ${id}`, po: `PO-${id}`, order_id: null, status: "pending", items: [], parsed_services: { string: "", array: [], }, created_at: "2026-01-01T09:00:00.000Z", datetime: "2026-01-01T09:00:00.000Z", ...overrides, }; } function buildMobileMatchedVehicle(reg, overrides = {}) { return { id: 7800, reg, customer_id: REGULAR_CUSTOMER_ID, customer_name: "Pleno Logistics", type: 53, status: "verified", barred: false, wash_subscription: false, addons: { enabled: 1, available: 2, list: [71, 41], }, reference: `REF-${reg}`, last_order_id: null, ...overrides, }; } function buildFixtureOrderItem(fixture, productId, overrides = {}, id = null) { const product = fixture.products.find((candidate) => Number(candidate.id) === Number(productId)); if (!product) { throw new Error(`Product ${productId} not found in fixture.`); } const resolvedPrice = Number(overrides.price ?? product.price ?? 0); return { id, order_id: overrides.order_id ?? DEFAULT_LAST_ORDER_ID, product_id: productId, price: resolvedPrice, quantity: Number(overrides.quantity ?? 1), related_item_id: overrides.related_item_id ?? null, include_in_invoice: overrides.include_in_invoice ?? true, notes: overrides.notes ?? "", product: { ...JSON.parse(JSON.stringify(product)), addons: [], price: resolvedPrice, }, }; } function buildTodayTimestamp(time = "08:00:00.000Z") { const todayIsoDate = new Date().toISOString().split("T")[0]; return `${todayIsoDate}T${time}`; } async function forceLocale(page, locale = "en") { await page.addInitScript((value) => { window.localStorage.setItem("locale", value); }, locale); } function createMultiBookingFixture({ reg, vehicle = {}, bookings = [] }) { const baseFixture = createMobilePosFixture(); return createMobilePosFixture({ vehicles: [...baseFixture.vehicles, buildMobileMatchedVehicle(reg, vehicle)], bookingsById: { ...baseFixture.bookingsById, ...Object.fromEntries(bookings.map((booking) => [booking.id, booking])), }, bookingStatusById: { ...baseFixture.bookingStatusById, ...Object.fromEntries(bookings.map((booking) => [booking.id, booking.status || "pending"])), }, }); } async function openMobileStep1FromStoredSnapshot(page, fixture, seedState, setupOptions = {}) { await setupMobilePosPage(page, fixture, { token: "mobile-step1-persistent-snapshot-token", seedState: false, route: { step: 1, }, ...setupOptions, }); const snapshot = buildMobilePosState({ fixture, customerId: null, reg: "", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, ...seedState, }); await page.evaluate((payload) => { window.localStorage.setItem("pos", JSON.stringify(payload)); }, snapshot); await gotoMobilePos(page, { departmentId: fixture.departmentId ?? DEFAULT_DEPARTMENT_ID, step: 1, }); } async function selectCustomerFromPopup(page, customerNumber = REGULAR_CUSTOMER_ID) { await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-customer-search-input").fill(String(customerNumber)); await expect(page.getByTestId("pos-mobile-customer-search-result-0")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-customer-search-result-0").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 }); } async function openAddCustomerPopup(page) { const popup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="select_customer"]'); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); await popup.getByText("Ny kunde", { exact: true }).click(); await expect(page.getByTestId("pos-mobile-add-customer-popup")).toBeVisible({ timeout: 10_000 }); } async function openCustomerPopupFromStep1(page) { await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); } async function waitForStepReset(page) { await expect .poll( () => { const currentUrl = new URL(page.url()); return currentUrl.searchParams.get("step") || "1"; }, { timeout: 12_000 } ) .toBe("1"); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); } async function expectCleanActionText(locator) { const text = await locator.textContent(); expect(containsSuspiciousEncoding(text ?? "")).toBe(false); } async function expectHorizontallyCentered(container, target, tolerance = 8) { const [containerBox, targetBox] = await Promise.all([container.boundingBox(), target.boundingBox()]); expect(containerBox).not.toBeNull(); expect(targetBox).not.toBeNull(); const containerCenter = containerBox.x + containerBox.width / 2; const targetCenter = targetBox.x + targetBox.width / 2; expect(Math.abs(containerCenter - targetCenter)).toBeLessThanOrEqual(tolerance); } async function expectSameRow(left, right, tolerance = 8) { const [leftBox, rightBox] = await Promise.all([left.boundingBox(), right.boundingBox()]); expect(leftBox).not.toBeNull(); expect(rightBox).not.toBeNull(); const leftCenterY = leftBox.y + leftBox.height / 2; const rightCenterY = rightBox.y + rightBox.height / 2; expect(Math.abs(leftCenterY - rightCenterY)).toBeLessThanOrEqual(tolerance); } async function expectAboveFixedActions(page, target, tolerance = 8) { const [targetBox, actionsBox] = await Promise.all([ target.boundingBox(), page.getByTestId("pos-mobile-fixed-actions").boundingBox(), ]); expect(targetBox).not.toBeNull(); expect(actionsBox).not.toBeNull(); expect(targetBox.y + targetBox.height).toBeLessThanOrEqual(actionsBox.y - tolerance); } async function expectPopupFooterAlignedToShell(page, tolerance = 8) { const footer = page.getByTestId("pos-mobile-popup-footer"); const shell = page.getByTestId("pos-mobile-popup-shell"); await expect(footer).toBeVisible({ timeout: 10_000 }); await expect(shell).toBeVisible({ timeout: 10_000 }); const [shellBox, footerBox] = await Promise.all([shell.boundingBox(), footer.boundingBox()]); expect(shellBox).not.toBeNull(); expect(footerBox).not.toBeNull(); const shellBottom = shellBox.y + shellBox.height; const footerBottom = footerBox.y + footerBox.height; expect(Math.abs(shellBottom - footerBottom)).toBeLessThanOrEqual(tolerance); expect(footerBox.y).toBeGreaterThan(shellBox.y + shellBox.height / 2); } async function selectPrimaryProduct(page, productId = 53) { await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-category-4")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-4").click(); await page.waitForTimeout(800); await expect(page.getByTestId(`pos-mobile-product-${productId}`)).toBeVisible({ timeout: 10_000 }); await page.getByTestId(`pos-mobile-product-${productId}`).click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); } async function openVehicleSelectionFromPrimaryProduct(page, productName = "Tank truck wash") { const trigger = page.getByTestId("pos-mobile-primary-product-card"); await expect(trigger).toBeVisible({ timeout: 10_000 }); if (productName) { await expect(trigger).toContainText(productName, { timeout: 10_000 }); } await trigger.dispatchEvent("pointerdown"); await page.waitForTimeout(650); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 }); } async function getPrimaryProductTouchPoint(page) { const trigger = page.getByTestId("pos-mobile-primary-product-card"); await expect(trigger).toBeVisible({ timeout: 10_000 }); const box = await trigger.boundingBox(); if (!box) { throw new Error("Primary product card bounding box is unavailable."); } const clientX = Math.round(box.x + box.width / 2); const clientY = Math.round(box.y + Math.min(box.height - 24, 40)); return { trigger, clientX, clientY, }; } async function dragAcrossPrimaryProduct(page, offsetY = 48) { const { trigger, clientX, clientY } = await getPrimaryProductTouchPoint(page); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await trigger.dispatchEvent("pointermove", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); await page.waitForTimeout(650); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); } async function getCopyLastWashTouchPoint(page) { const trigger = page.getByTestId("pos-mobile-copy-last-wash"); await expect(trigger).toBeVisible({ timeout: 10_000 }); const box = await trigger.boundingBox(); if (!box) { throw new Error("Copy last wash button bounding box is unavailable."); } const clientX = Math.round(box.x + box.width / 2); const clientY = Math.round(box.y + box.height / 2); return { trigger, clientX, clientY, }; } async function dragAcrossCopyLastWash(page, offsetY = 48) { const { trigger, clientX, clientY } = await getCopyLastWashTouchPoint(page); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await trigger.dispatchEvent("pointermove", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); await page.waitForTimeout(650); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); } async function longPressCopyLastWash(page, waitMs = 650) { const { trigger, clientX, clientY } = await getCopyLastWashTouchPoint(page); const triggerHandle = await trigger.elementHandle(); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await page.waitForTimeout(waitMs); if (triggerHandle) { await triggerHandle .dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY, }) .catch(() => {}); } } async function getAdditionalItemsTouchPoint(page) { const trigger = page.getByTestId("pos-mobile-additional-items-open"); await expect(trigger).toBeVisible({ timeout: 10_000 }); const box = await trigger.boundingBox(); if (!box) { throw new Error("Additional items button bounding box is unavailable."); } const clientX = Math.round(box.x + box.width / 2); const clientY = Math.round(box.y + box.height / 2); return { trigger, clientX, clientY, }; } async function dragAcrossAdditionalItems(page, offsetY = 48) { const { trigger, clientX, clientY } = await getAdditionalItemsTouchPoint(page); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await trigger.dispatchEvent("pointermove", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); await page.waitForTimeout(650); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY: clientY - offsetY, }); } async function longPressAdditionalItems(page, waitMs = 650) { const { trigger, clientX, clientY } = await getAdditionalItemsTouchPoint(page); const triggerHandle = await trigger.elementHandle(); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await page.waitForTimeout(waitMs); if (triggerHandle) { await triggerHandle .dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY, }) .catch(() => {}); } } async function waitForBookingHydration(page, { primaryId, addonProductIds = [] } = {}) { await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); const addonIds = (snapshot?.transactionItems?.primaryItem?.addons || []) .map((addon) => Number(addon?.product?.id ?? addon?.id)) .sort((left, right) => left - right); return { primaryId: snapshot?.transactionItems?.primaryItem?.id ?? null, addonIds, }; }, { timeout: 10_000 } ) .toEqual({ primaryId, addonIds: [...addonProductIds].sort((left, right) => left - right), }); } async function waitForOrderBookingPopup(page) { const popup = page.getByTestId("pos-mobile-order-booking-popup"); await expect(popup).toBeVisible({ timeout: 10_000 }); return popup; } async function expectOrderBookingPopupIds(page, bookingIds) { await expect .poll( async () => page .locator('[data-testid^="pos-mobile-order-booking-option-"]') .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))), { timeout: 10_000 } ) .toEqual(bookingIds.map((bookingId) => `pos-mobile-order-booking-option-${bookingId}`)); } async function openMobileOrderBookingSettings(page, bookingId) { const settings = page.getByTestId(`pos-mobile-order-booking-settings-${bookingId}`); await expect(settings).toBeVisible({ timeout: 10_000 }); await settings.locator(".dropdown-trigger button").click(); const dropdown = settings.locator(".dropdown-content"); await expect(dropdown).toBeVisible({ timeout: 10_000 }); return dropdown; } async function completeMobileOrderBookingFromSettings(page, bookingId) { const dropdown = await openMobileOrderBookingSettings(page, bookingId); await dropdown.locator("button.dropdown-item-action").first().click(); const popup = page.locator(".swal2-popup"); await expect(popup).toBeVisible({ timeout: 10_000 }); await popup.locator(".swal2-deny").click(); await expect(popup).toBeHidden({ timeout: 10_000 }); } async function waitForMobileStepTwoReady(page) { const stepTwoShell = page.getByTestId("pos-mobile-step-2"); const vehicleSelection = page.getByTestId("pos-mobile-vehicle-selection"); const resolvedView = await Promise.race([ stepTwoShell .waitFor({ state: "visible", timeout: 10_000 }) .then(() => "step-2") .catch(() => null), vehicleSelection .waitFor({ state: "visible", timeout: 10_000 }) .then(() => "vehicle-selection") .catch(() => null), ]); expect(resolvedView).not.toBeNull(); } async function confirmRequiredReferencePromptIfVisible(page, referenceValue) { const popup = page.locator(".swal2-popup"); const didOpen = await popup .waitFor({ state: "visible", timeout: 1_000 }) .then(() => true) .catch(() => false); if (!didOpen) { return false; } if (referenceValue === null) { return false; } await popup.locator(".swal2-input").fill(referenceValue); await popup.locator(".swal2-confirm").click(); await expect(popup).toBeHidden({ timeout: 10_000 }); return true; } async function createOrderFromStep1( page, fixture, { reg = "FREE123", reference = "STEP1-REF", manualInput = false, customerNumber = REGULAR_CUSTOMER_ID, extraSeedState = {}, requiredReference = null, } = {} ) { await setupMobilePosPage(page, fixture, { token: `step1-${reg.toLowerCase()}-token`, seedState: { customerId: null, reg, reference, includePrimaryItem: false, vehicleType: null, lastOrderId: null, ...extraSeedState, }, route: { step: 1, }, }); if (manualInput) { await page.getByTestId("pos-mobile-manual-input-toggle").click(); await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-reg-input-1").fill(reg.toLowerCase()); await expect(page.getByTestId("pos-mobile-reg-input-1")).toHaveValue(reg.toUpperCase()); await page.getByTestId("pos-mobile-manual-input-close").click(); await expect(page.getByTestId("pos-mobile-manual-input")).toBeHidden({ timeout: 10_000 }); } await page.getByTestId("pos-mobile-next-step").click(); const customerPopupAppeared = await page .getByTestId("pos-mobile-customer-popup") .waitFor({ state: "visible", timeout: 5_000 }) .then(() => true) .catch(() => false); if (!customerPopupAppeared) { const confirmedRequiredReferencePrompt = await confirmRequiredReferencePromptIfVisible(page, requiredReference); await waitForMobileStepTwoReady(page); return { confirmedRequiredReferencePrompt }; } await selectCustomerFromPopup(page, customerNumber); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); const confirmedRequiredReferencePrompt = await confirmRequiredReferencePromptIfVisible(page, requiredReference); await waitForMobileStepTwoReady(page); return { confirmedRequiredReferencePrompt }; } test("mobile customer popup exposes the draft quick action and selects the configured draft customer", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); const fixture = createMobilePosFixture({ customersByNumber: { [MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID]: { id: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, customerNumber: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, name: MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME, address: "Draft Street 7", zip: "2630", city: "Taastrup", mobilePhone: "44556677", email: "draft-mobile@example.com", corporateIdentificationNumber: "44556677", barred: false, economic_customer: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, }, }, }); await setupMobilePosPage(page, fixture, { token: "pos-mobile-draft-customer-token", seedState: { customerId: null, includePrimaryItem: false, reg: "FREE123", reference: "MOBILE-DRAFT", }, route: { step: 1, }, sessionData: { runtime_config: { economic: { transaction_draft_customer_number: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, }, }, }, }); await openCustomerPopupFromStep1(page); await expect(page.getByTestId("pos-mobile-draft-customer")).toBeVisible(); await page.getByTestId("pos-mobile-draft-customer").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 }); await expect .poll(async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.metadata?.customerId ?? snapshot?.vehicles?.vehicle_1?.customer_id ?? null; }) .toBe(MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); await waitForMobileStepTwoReady(page); await expect(page.getByTestId("pos-mobile-customer-name")).toContainText(MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME); }); test("mobile customer popup applies a previous-customer suggestion", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); const suggestedCustomerId = 55667788; const suggestedCustomerName = "Suggestion Logistics"; const secondarySuggestedCustomerId = 66778899; const fixture = createMobilePosFixture({ customersByNumber: { [suggestedCustomerId]: { id: suggestedCustomerId, customerNumber: suggestedCustomerId, name: suggestedCustomerName, address: "Suggestion Street 8", zip: "2630", city: "Taastrup", mobilePhone: "55667788", email: "suggestion@example.com", corporateIdentificationNumber: "55667788", barred: false, economic_customer: suggestedCustomerId, }, [secondarySuggestedCustomerId]: { id: secondarySuggestedCustomerId, customerNumber: secondarySuggestedCustomerId, name: "Fallback Suggestion", address: "Fallback Street 9", zip: "2630", city: "Taastrup", mobilePhone: "66778899", email: "fallback@example.com", corporateIdentificationNumber: "66778899", barred: false, economic_customer: secondarySuggestedCustomerId, }, }, vehicleCustomerSuggestionsByReg: { FREE123: [ { id: 901, customer_number: suggestedCustomerId, customer_name: suggestedCustomerName, barred: false, }, { id: 902, customer_number: secondarySuggestedCustomerId, customer_name: "Fallback Suggestion", barred: false, }, ], }, }); await setupMobilePosPage(page, fixture, { token: "pos-mobile-previous-customer-suggestion-token", seedState: { customerId: null, includePrimaryItem: false, reg: "FREE123", reference: "MOBILE-SUGGESTION", lastOrderId: null, }, route: { step: 1, }, }); await openCustomerPopupFromStep1(page); await expect(page.getByTestId("pos-customer-suggestions")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`pos-customer-suggestion-select-${suggestedCustomerId}`)).toBeVisible({ timeout: 10_000, }); await page.getByTestId(`pos-customer-suggestion-select-${suggestedCustomerId}`).click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 }); await expect .poll(async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.metadata?.customerId ?? null; }) .toBe(suggestedCustomerId); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); await waitForMobileStepTwoReady(page); await expect(page.getByTestId("pos-mobile-customer-name")).toContainText(suggestedCustomerName); }); test("mobile customer popup defaults to customer invoice mode and keeps customer search visible", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); const fixture = createMobilePosFixture({ customersByNumber: { [MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID]: { id: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, customerNumber: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, name: MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME, address: "Draft Street 7", zip: "2630", city: "Taastrup", mobilePhone: "44556677", email: "draft-mobile@example.com", corporateIdentificationNumber: "44556677", barred: false, economic_customer: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, }, }, }); await setupMobilePosPage(page, fixture, { token: "pos-mobile-customer-invoice-mode-token", seedState: { customerId: null, includePrimaryItem: false, reg: "FREE123", reference: "MOBILE-INVOICE-MODE", lastOrderId: null, }, route: { step: 1, }, sessionData: { runtime_config: { economic: { transaction_draft_customer_number: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID, }, }, }, }); await openCustomerPopupFromStep1(page); await expect(page.getByTestId("pos-mobile-customer-invoice")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("pos-mobile-customer-search-input")).toBeVisible(); await expect(page.getByTestId("pos-mobile-draft-customer")).toBeVisible(); await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible(); }); test("mobile customer popup uses a taller shell when the viewport can fit it", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "pos-mobile-customer-popup-height-token", seedState: { customerId: null, includePrimaryItem: false, reg: "FREE123", reference: "MOBILE-CUSTOMER-POPUP-HEIGHT", lastOrderId: null, }, route: { step: 1, }, }); await openCustomerPopupFromStep1(page); const popupShell = page.getByTestId("pos-mobile-popup-shell"); await expect(popupShell).toBeVisible({ timeout: 10_000 }); const popupShellBox = await popupShell.boundingBox(); const viewportSize = page.viewportSize(); expect(popupShellBox).not.toBeNull(); expect(viewportSize).not.toBeNull(); expect((popupShellBox?.height ?? 0) / (viewportSize?.height ?? 1)).toBeGreaterThan(0.6); }); test("step 2 customer banner shows a chevron and reopens the customer picker", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "pos-mobile-step-2-customer-banner-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "EC21235", reference: "STEP2-CUSTOMER-BANNER", includePrimaryItem: true, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId: 54518, customerId: REGULAR_CUSTOMER_ID, }, }); await waitForMobileStepTwoReady(page); await expect(page.getByTestId("pos-mobile-customer-banner")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-customer-banner-chevron")).toBeVisible(); await page.getByTestId("pos-mobile-customer-banner").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); }); test.describe("POS mobile order flow", () => { test.beforeEach(async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile."); await suppressVueDevtoolsOverlay(page); }); test("scanner shell renders and prompts for customer selection", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-shell-token", seedState: { customerId: null, reg: "FREE123", reference: "SHELL-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-1-title")).toContainText("Scan nummerplader"); await expect(page.getByTestId("pos-mobile-step-1-subtitle")).toContainText("Hold kameraet op mod nummerpladerne."); await expectHorizontallyCentered( page.getByTestId("pos-mobile-step-1-shell"), page.getByTestId("pos-mobile-step-1-title") ); await expectHorizontallyCentered( page.getByTestId("pos-mobile-step-1-shell"), page.getByTestId("pos-mobile-step-1-subtitle") ); await expectHorizontallyCentered( page.getByTestId("pos-mobile-step-1-shell"), page.getByTestId("pos-mobile-manual-input-toggle") ); const [registrationRowBox, referenceTriggerBox] = await Promise.all([ page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(), page.getByTestId("pos-mobile-step-1-reference-trigger").boundingBox(), ]); expect(registrationRowBox).not.toBeNull(); expect(referenceTriggerBox).not.toBeNull(); expect(Math.abs((registrationRowBox?.width ?? 0) - (referenceTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4); expect(Math.abs((registrationRowBox?.x ?? 0) - (referenceTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-select-customer-header-close")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-select-customer-header-close").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 }); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 }); }); test("mobile header department button opens a touch menu on the scanner screen", async ({ page }) => { const fixture = createMobilePosFixture({ departments: [ { id: 1, name: "Taastrup", visible: true, order_priority: 1 }, { id: 2, name: "Odense", visible: true, order_priority: 2 }, ], }); await setupMobilePosPage(page, fixture, { token: "mobile-header-department-menu-token", permissions: ["admin", "department_access_1", "department_access_2"], seedState: { customerId: null, reg: "FREE123", reference: "HEADER-MENU", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("mobile-header-department-toggle")).toContainText("Taastrup"); await page.getByTestId("mobile-header-department-toggle").tap(); await expect(page.getByTestId("mobile-header-department-menu")).toBeVisible(); await expect(page.getByTestId("mobile-header-department-option-2")).toContainText("Odense"); await page.getByTestId("mobile-header-department-option-2").tap(); await expect(page).toHaveURL(/\/admin\/2\/modules\/pos\?step=1$/); }); test("add-customer popup preserves manual edits while refreshing untouched fields from a later CVR lookup", async ({ page, }) => { const fixture = createMobilePosFixture({ cvrSearchResponses: { 41004355: { data: { vat: 41004355, status: "Normal", name: "Truckwash ApS", address: "Letland Alle 2", zipcode: 2630, city: "Taastrup", phone: "21754690", email: "mikkel@truckwash.dk", }, }, 43423010: { data: { vat: 43423010, status: "Normal", name: "Wash Group ApS", address: "Nordhavn 4", zipcode: 2100, city: "Kobenhavn O", phone: "42331128", email: "billing@wash-group.test", }, }, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-add-customer-reset-fix-token", seedState: { customerId: null, reg: "FREE123", reference: "ADD-CUSTOMER-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.getByTestId("pos-mobile-next-step").click(); await openAddCustomerPopup(page); const searchInput = page.getByTestId("pos-mobile-add-customer-search-input"); const companyPhone = page.getByTestId("pos-mobile-add-customer-company-phone"); const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email"); const contactEmail = page.getByTestId("pos-mobile-add-customer-contact-email"); const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone"); const ean = page.getByTestId("pos-mobile-add-customer-ean"); const submitButton = page.getByTestId("pos-mobile-add-customer-submit"); await searchInput.fill("41004355"); await expect(companyPhone).toHaveValue("21754690", { timeout: 10_000 }); await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactEmail).toHaveValue("mikkel@truckwash.dk"); await expect(contactPhone).toHaveValue("21754690"); await expect(ean).toHaveValue(""); await expect(submitButton).toBeVisible({ timeout: 10_000 }); await companyPhone.fill("55550000"); await contactEmail.fill("dispatch@truckwash.test"); await ean.fill("5790001234567"); await searchInput.fill("43423010"); await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 }); await expect(contactEmail).toHaveValue("dispatch@truckwash.test"); await expect(invoiceEmail).toHaveValue("billing@wash-group.test"); await expect(contactPhone).toHaveValue("42331128"); await expect(ean).toHaveValue("5790001234567"); await expect(submitButton).toBeVisible({ timeout: 10_000 }); }); test("add-customer popup keeps footer anchored while the popup body scrolls", async ({ page }) => { const fixture = createMobilePosFixture(); await page.setViewportSize({ width: 393, height: 480 }); await setupMobilePosPage(page, fixture, { token: "mobile-add-customer-footer-anchor-token", seedState: { customerId: null, reg: "FREE123", reference: "ADD-CUSTOMER-FOOTER-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.getByTestId("pos-mobile-next-step").click(); await openAddCustomerPopup(page); await page.getByTestId("pos-mobile-popup-shell").evaluate((element) => { element.style.height = "16rem"; element.style.maxHeight = "16rem"; }); const scrollRegion = page.getByTestId("pos-mobile-popup-scroll-region"); const footer = page.getByTestId("pos-mobile-popup-footer"); await scrollRegion.evaluate((element) => { const spacer = document.createElement("div"); spacer.setAttribute("data-testid", "pos-mobile-add-customer-scroll-spacer"); spacer.style.height = "20rem"; spacer.style.flexShrink = "0"; element.appendChild(spacer); }); await expect(page.getByTestId("pos-mobile-popup-shell")).toBeVisible({ timeout: 10_000 }); await expect(footer).toBeVisible({ timeout: 10_000 }); await expectAboveFixedActions(page, footer); const footerBeforeScroll = await footer.boundingBox(); await expect.poll(() => scrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); await scrollRegion.evaluate((element) => { element.scrollTop = element.scrollHeight; }); await expect.poll(() => scrollRegion.evaluate((element) => Math.round(element.scrollTop))).toBeGreaterThan(0); const footerAfterScroll = await footer.boundingBox(); expect(footerBeforeScroll).not.toBeNull(); expect(footerAfterScroll).not.toBeNull(); expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2); expect( Math.abs(footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)) ).toBeLessThanOrEqual(2); await expectAboveFixedActions(page, footer); }); test("add-customer popup ignores stale delayed CVR responses after a newer lookup resolves", async ({ page }) => { const fixture = createMobilePosFixture({ cvrSearchResponses: { 41004355: { delayMs: 700, data: { vat: 41004355, status: "Normal", name: "Truckwash ApS", address: "Letland Alle 2", zipcode: 2630, city: "Taastrup", phone: "21754690", email: "mikkel@truckwash.dk", }, }, 43423010: { data: { vat: 43423010, status: "Normal", name: "Wash Group ApS", address: "Nordhavn 4", zipcode: 2100, city: "Kobenhavn O", phone: "42331128", email: "billing@wash-group.test", }, }, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-add-customer-stale-search-token", seedState: { customerId: null, reg: "FREE123", reference: "ADD-CUSTOMER-RACE-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.getByTestId("pos-mobile-next-step").click(); await openAddCustomerPopup(page); const searchInput = page.getByTestId("pos-mobile-add-customer-search-input"); const companyPhone = page.getByTestId("pos-mobile-add-customer-company-phone"); const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email"); const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone"); await searchInput.fill("41004355"); await page.waitForTimeout(350); await searchInput.fill("43423010"); await expect(companyPhone).toHaveValue("42331128", { timeout: 10_000 }); await expect(invoiceEmail).toHaveValue("billing@wash-group.test"); await expect(contactPhone).toHaveValue("42331128"); await page.waitForTimeout(900); await expect(companyPhone).toHaveValue("42331128"); await expect(invoiceEmail).toHaveValue("billing@wash-group.test"); await expect(contactPhone).toHaveValue("42331128"); await expect.poll(() => fixture.requestCounters.cvrSearchGet, { timeout: 10_000 }).toBe(2); }); test("transaction history keeps each wash visually separated with a border", async ({ page }) => { const todayDate = new Date().toISOString().split("T")[0]; const fixture = createMobilePosFixture({ ordersById: { 9201: buildRegularOrder(9201, { customer_name: "VOGNMAND JIMMY CHRISTENSEN ApS", total_net_amount: 746, pending_handheld: true, created_at: `${todayDate}T12:08:00.000Z`, }), 9202: buildRegularOrder(9202, { customer_name: "BYGMA Roskilde A/S", total_net_amount: 507, pending_handheld: true, created_at: `${todayDate}T11:51:00.000Z`, }), 9203: buildRegularOrder(9203, { customer_name: "VOLVO ENTREPRENØRMASKINER A/S", total_net_amount: 512, pending_handheld: true, created_at: `${todayDate}T10:10:00.000Z`, }), }, }); await setupMobilePosPage(page, fixture, { token: "mobile-transaction-history-token", seedState: { customerId: null, reg: "FREE123", reference: "HISTORY-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, transactionHistoryView: true, }, route: { step: 1, }, }); const cards = page.locator('[data-testid^="pos-mobile-transaction-history-card-"]'); await expect(cards.first()).toBeVisible({ timeout: 10_000 }); expect(await cards.count()).toBe(3); await expect(cards.first()).toHaveCSS("border-top-style", "solid"); await expect(cards.first()).toHaveCSS("border-top-width", "1px"); }); test("customer notes popup shows the translated created-by label", async ({ page }) => { const fixture = createMobilePosFixture({ customerNotesByNumber: { [REGULAR_CUSTOMER_ID]: [ { id: 401, note: "test123", created_at: "2026-04-09T06:17:00.000Z", cashier_id: 7, }, ], }, employees: [ { id: 7, display_name: "Jeppe", }, ], }); await setupMobilePosPage(page, fixture, { token: "mobile-customer-notes-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "NOTES-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-customer-notes-trigger")).toBeVisible({ timeout: 10_000 }); const [registrationRowBox, notesTriggerBox] = await Promise.all([ page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(), page.getByTestId("pos-mobile-customer-notes-trigger").boundingBox(), ]); expect(registrationRowBox).not.toBeNull(); expect(notesTriggerBox).not.toBeNull(); expect(Math.abs((registrationRowBox?.width ?? 0) - (notesTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4); expect(Math.abs((registrationRowBox?.x ?? 0) - (notesTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4); await page.getByTestId("pos-mobile-customer-notes-trigger").click(); const popup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="customer_notes"]'); await expect(popup).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-customer-note-created-by-401")).toContainText("Oprettet af Jeppe"); await expect(page.getByTestId("pos-mobile-customer-note-created-by-401")).not.toContainText("pos.created_by"); await popup.getByTestId("pos-mobile-popup-add-note-action").click(); await expect(page.locator("#input_field")).toBeVisible({ timeout: 10_000 }); await expect(page.locator("#input_field")).toHaveAttribute("placeholder", "Tilføj"); }); test("step 1 reference trigger shows the required warning state and preserves row alignment", async ({ page }) => { const fixture = createMobilePosFixture({ customerAttributesByNumber: { [REGULAR_CUSTOMER_ID]: [ { id: 9, customer_number: REGULAR_CUSTOMER_ID, attribute: "requiresReferenceNumber", }, ], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-required-reference-warning-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "WARN123", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); const referenceTrigger = page.getByTestId("pos-mobile-step-1-reference-trigger"); await expect(referenceTrigger).toBeVisible({ timeout: 10_000 }); await expect(referenceTrigger).toHaveAttribute("data-warning-state", "danger"); await expect(page.getByTestId("pos-mobile-step-1-reference-warning-icon")).toBeVisible(); const [registrationRowBox, referenceTriggerBox] = await Promise.all([ page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(), referenceTrigger.boundingBox(), ]); expect(registrationRowBox).not.toBeNull(); expect(referenceTriggerBox).not.toBeNull(); expect(Math.abs((registrationRowBox?.width ?? 0) - (referenceTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4); expect(Math.abs((registrationRowBox?.x ?? 0) - (referenceTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4); await referenceTrigger.click(); await expect(page.getByTestId("pos-mobile-reference-input")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-reference-input").fill("MOBILE-WARN-REF"); await page.getByTestId("pos-mobile-reference-input").press("Enter"); await expect(page.getByTestId("pos-mobile-reference-input")).toHaveCount(0); await expect(referenceTrigger).not.toHaveAttribute("data-warning-state", "danger"); await expect(page.getByTestId("pos-mobile-step-1-reference-warning-icon")).toHaveCount(0); }); test("@smoke @pr manual input happy path creates, completes, and resets", async ({ page }) => { const fixture = createMobilePosFixture(); await createOrderFromStep1(page, fixture, { reg: "ZZ00000", reference: "MOBILE-SMOKE-REF", manualInput: true, }); await selectPrimaryProduct(page, 53); await waitForMobileNextStepCooldown(page); const orderId = Number(new URL(page.url()).searchParams.get("id")); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); expect(fixture.markCompletedOrderIds).toContain(orderId); await waitForStepReset(page); }); test("stored step 1 vehicles without status normalize to unknown without Vue prop warnings", async ({ page }) => { const fixture = createMobilePosFixture(); const consoleProblems = []; const pageErrors = []; page.on("console", (message) => { if (message.type() === "error" || message.type() === "warning" || message.text().includes('prop "status"')) { consoleProblems.push(`${message.type()}: ${message.text()}`); } }); page.on("pageerror", (error) => { pageErrors.push(error.stack || error.message); }); await setupMobilePosPage(page, fixture, { token: "stored-step1-missing-status-token", seedState: false, route: { step: 1, }, }); await page.evaluate(() => { const storedValue = window.localStorage.getItem("pos"); const snapshot = storedValue ? JSON.parse(storedValue) : {}; snapshot.vehicles = snapshot.vehicles || {}; snapshot.vehicles.vehicle_1 = { reg: "EC2123", customer_id: null, type: null, booking_id: null, booking_matches: [], wash_subscription: false, barred: false, reference: "", last_order_id: null, }; snapshot.vehicles.vehicle_2 = null; snapshot.vehicles.vehicle_3 = null; snapshot.vehicles.activeVehicleIndex = 1; window.localStorage.setItem("pos", JSON.stringify(snapshot)); }); await page.reload(); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { reg: snapshot?.vehicles?.vehicle_1?.reg ?? null, status: snapshot?.vehicles?.vehicle_1?.status ?? null, }; }, { timeout: 10_000 } ) .toEqual({ reg: "EC2123", status: "unknown", }); const combinedProblems = [...consoleProblems, ...pageErrors].join("\n"); expect(combinedProblems).not.toContain('Invalid prop: type check failed for prop "status"'); expect(combinedProblems).not.toContain('Expected String with value "undefined"'); }); test("matched vehicle manual input seeds the step 2 reference and primary product defaults", async ({ page }) => { const fixture = createMobilePosFixture(); await createOrderFromStep1(page, fixture, { reg: "AB12345", reference: "", manualInput: true, extraSeedState: { reference: "", }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345"); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(53); }); test("multiple matching bookings open immediately, keep the chosen booking, and complete that exact booking", async ({ page, }) => { const fixture = createMultiBookingFixture({ reg: "MULTI123", vehicle: { reference: "VEHICLE-MULTI-REF", }, bookings: [ buildMobileOrderBooking(8201, { reg_1: "TRACT8201", reg_2: "MULTI123", datetime: "2026-01-01T07:00:00.000Z", reference: "MULTI-BOOKING-A", reference_number: "MULTI-BOOKING-A", items: [ { id: 53, name: "Tank truck wash", price: 599, quantity: 1 }, { id: 71, name: "Interior rinse", price: 99, quantity: 1 }, ], parsed_services: { string: "Tank truck wash, Interior rinse", array: ["Tank truck wash", "Interior rinse"], }, }), buildMobileOrderBooking(8202, { reg_1: "TRACT8202", reg_2: "MULTI123", datetime: "2026-01-01T09:00:00.000Z", reference: "MULTI-BOOKING-B", reference_number: "MULTI-BOOKING-B", notes: "Selected mobile booking", note: "Selected mobile booking", items: [{ id: 63, name: "Box trailer wash", price: 499, quantity: 1 }], parsed_services: { string: "Box trailer wash", array: ["Box trailer wash"], }, }), ], }); await setupMobilePosPage(page, fixture, { token: "mobile-multi-booking-select-token", seedState: { customerId: null, reg: "MULTI123", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await waitForOrderBookingPopup(page); await expectOrderBookingPopupIds(page, [8201, 8202]); await page.getByTestId("pos-mobile-order-booking-use-8202").click(); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null, reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "", reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "", vehicle2BookingId: snapshot?.vehicles?.vehicle_2?.booking_id ?? null, reference: snapshot?.metadata?.reference ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: 8202, vehicleBookingId: 8202, reg1: "TRACT8202", reg2: "MULTI123", vehicle2BookingId: 8202, reference: "MULTI-BOOKING-B", }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.ordersById[9300]?.booking_id ?? null, { timeout: 10_000 }).toBe(8202); await waitForBookingHydration(page, { primaryId: 63, addonProductIds: [], }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("MULTI-BOOKING-B"); await expect .poll( () => ({ reg_1: fixture.ordersById[9300]?.reg_1 ?? "", reg_2: fixture.ordersById[9300]?.reg_2 ?? "", }), { timeout: 10_000 } ) .toEqual({ reg_1: "TRACT8202", reg_2: "MULTI123", }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect .poll( () => fixture.requestLog.bookingOrderAssignments.some( (entry) => Number(entry?.id) === 8202 && Number(entry?.order_id) === 9300 ), { timeout: 10_000 } ) .toBe(true); await expect .poll(() => fixture.requestLog.bookingCompletions.some((entry) => Number(entry?.id) === 8202), { timeout: 10_000, }) .toBe(true); expect(fixture.requestLog.bookingCompletions.some((entry) => Number(entry?.id) === 8201)).toBe(false); await waitForStepReset(page); }); test("mobile booking popup sorts today's bookings first and highlights the today option", async ({ page }) => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const fixture = createMultiBookingFixture({ reg: "TODAYMOB", vehicle: { reference: "REF-TODAYMOB", }, bookings: [ buildMobileOrderBooking(8601, { reg_1: "TODAY-TRAILER-B", reg_2: "TODAYMOB", datetime: tomorrow.toISOString(), reference: "TODAY-MOBILE-B", reference_number: "TODAY-MOBILE-B", }), buildMobileOrderBooking(8600, { reg_1: "TODAY-TRAILER-A", reg_2: "TODAYMOB", datetime: buildTodayTimestamp("08:00:00.000Z"), reference: "TODAY-MOBILE-A", reference_number: "TODAY-MOBILE-A", }), ], }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-today-priority-token", seedState: { customerId: null, reg: "TODAYMOB", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); const popup = await waitForOrderBookingPopup(page); await expectOrderBookingPopupIds(page, [8600, 8601]); await expect(popup.getByTestId("pos-mobile-order-booking-today-8600")).toBeVisible({ timeout: 10_000 }); const isTodayCardHighlighted = await popup .getByTestId("pos-mobile-order-booking-option-8600") .evaluate((element) => element.classList.contains("booking-option--today")); expect(isTodayCardHighlighted).toBe(true); }); test("mobile booking popup hydrates stripped booking list details into service rows and totals", async ({ page }) => { const fixture = createMultiBookingFixture({ reg: "HYDRATE1", vehicle: { reference: "REF-HYDRATE1", }, bookings: [ buildMobileOrderBooking(8701, { reg_1: "HYDRACT1", reg_2: "HYDRATE1", datetime: "2026-01-01T07:00:00.000Z", reference: "HYDRATE-A", reference_number: "HYDRATE-A", items: [ { id: 53, name: "Tank truck wash", price: 599, quantity: 1 }, { id: 71, name: "Interior rinse", price: 99, quantity: 1 }, ], parsed_services: { string: "Tank truck wash, Interior rinse", array: ["Tank truck wash", "Interior rinse"], }, }), buildMobileOrderBooking(8702, { reg_1: "HYDRACT2", reg_2: "HYDRATE1", datetime: "2026-01-01T09:00:00.000Z", reference: "HYDRATE-B", reference_number: "HYDRATE-B", items: [{ id: 63, name: "Box trailer wash", price: 499, quantity: 1 }], parsed_services: { string: "Box trailer wash", array: ["Box trailer wash"], }, }), ], }); fixture.orderBookingListStripsDetails = true; await setupMobilePosPage(page, fixture, { token: "mobile-booking-hydration-details-token", seedState: { customerId: null, reg: "HYDRATE1", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); const popup = await waitForOrderBookingPopup(page); await expectOrderBookingPopupIds(page, [8701, 8702]); await expect(popup.getByTestId("pos-mobile-order-booking-settings-8701")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 }).toBeGreaterThanOrEqual(3); await expect(popup.getByTestId("pos-mobile-order-booking-services-8701")).toContainText("Tank truck wash", { timeout: 10_000, }); await expect(popup.getByTestId("pos-mobile-order-booking-services-8701")).toContainText("Interior rinse", { timeout: 10_000, }); await expect(popup.getByTestId("pos-mobile-order-booking-service-row-8701-0")).toContainText("1x", { timeout: 10_000, }); await expect(popup.getByTestId("pos-mobile-order-booking-service-row-8701-1")).toContainText("Interior", { timeout: 10_000, }); await expect(popup.getByTestId("pos-mobile-order-booking-service-total-8701")).toContainText("698", { timeout: 10_000, }); }); test("mobile booking settings refresh auto-resolves to the last remaining booking after completion", async ({ page, }) => { const fixture = createMultiBookingFixture({ reg: "WHEELMOB", vehicle: { reference: "REF-WHEELMOB", }, bookings: [ buildMobileOrderBooking(8801, { reg_1: "WHEELTRACTA", reg_2: "WHEELMOB", datetime: "2026-01-01T07:00:00.000Z", reference: "WHEEL-MOBILE-A", reference_number: "WHEEL-MOBILE-A", }), buildMobileOrderBooking(8802, { reg_1: "WHEELTRACTB", reg_2: "WHEELMOB", datetime: "2026-01-01T09:00:00.000Z", reference: "WHEEL-MOBILE-B", reference_number: "WHEEL-MOBILE-B", }), ], }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-wheel-refresh-token", seedState: { customerId: null, reg: "WHEELMOB", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await waitForOrderBookingPopup(page); await expectOrderBookingPopupIds(page, [8801, 8802]); await completeMobileOrderBookingFromSettings(page, 8801); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "", reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "", reference: snapshot?.metadata?.reference ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: 8802, reg1: "WHEELTRACTB", reg2: "WHEELMOB", reference: "WHEEL-MOBILE-B", }); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.bookingsById[8801]?.status ?? "", { timeout: 10_000 }).toBe("completed"); expect(fixture.bookingsById[8802]?.status ?? "").toBe("pending"); }); test("booking popup keeps continue-without-booking visible and allows scrolling through long booking lists", async ({ page, }) => { const longBookingList = Array.from({ length: 8 }, (_, index) => buildMobileOrderBooking(8260 + index, { reg_1: `TRAC${8260 + index}`, reg_2: "MOBILELONG", datetime: `2026-01-${String(index + 1).padStart(2, "0")}T09:00:00.000Z`, reference: `LONG-${8260 + index}`, reference_number: `LONG-${8260 + index}`, }) ); const fixture = createMultiBookingFixture({ reg: "MOBILELONG", bookings: longBookingList, }); await page.setViewportSize({ width: 346, height: 610 }); await setupMobilePosPage(page, fixture, { token: "mobile-multi-booking-long-list-token", seedState: { customerId: null, reg: "MOBILELONG", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); const popup = await waitForOrderBookingPopup(page); const popupContent = page.getByTestId("pos-mobile-popup-scroll-region"); const footer = page.getByTestId("pos-mobile-popup-footer"); await expect(footer).toBeVisible({ timeout: 10_000 }); await expectPopupFooterAlignedToShell(page); await expectAboveFixedActions(page, footer); await expect .poll(async () => popupContent.evaluate((element) => element.scrollHeight > element.clientHeight), { timeout: 10_000, }) .toBe(true); const footerBeforeScroll = await footer.boundingBox(); await popupContent.evaluate((element) => { element.scrollTop = element.scrollHeight; }); await expect.poll(() => popupContent.evaluate((element) => Math.round(element.scrollTop))).toBeGreaterThan(0); const footerAfterScroll = await footer.boundingBox(); expect(footerBeforeScroll).not.toBeNull(); expect(footerAfterScroll).not.toBeNull(); expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2); expect( Math.abs(footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)) ).toBeLessThanOrEqual(2); await expectAboveFixedActions(page, footer); await expect(popup.getByTestId("pos-mobile-order-booking-option-8267")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-order-booking-skip").click(); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 }); }); test("manual input can continue without booking and keeps the flow unbooked", async ({ page }) => { const fixture = createMultiBookingFixture({ reg: "SKIP123", vehicle: { reference: "SKIP-VEHICLE-REF", }, bookings: [ buildMobileOrderBooking(8301, { reg_1: "SKIP123", datetime: "2026-01-01T07:00:00.000Z", reference: "SKIP-BOOKING-A", reference_number: "SKIP-BOOKING-A", }), buildMobileOrderBooking(8302, { reg_1: "SKIP123", datetime: "2026-01-01T09:00:00.000Z", reference: "SKIP-BOOKING-B", reference_number: "SKIP-BOOKING-B", }), ], }); await setupMobilePosPage(page, fixture, { token: "mobile-multi-booking-skip-token", seedState: { customerId: null, reg: "", reference: "", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.getByTestId("pos-mobile-manual-input-toggle").click(); await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-reg-input-1").fill("skip123"); await waitForOrderBookingPopup(page); await expectOrderBookingPopupIds(page, [8301, 8302]); await expect(page.getByTestId("pos-mobile-order-booking-skip")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-order-booking-skip").click(); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, reference: snapshot?.metadata?.reference ?? "", skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: null, reference: "SKIP-VEHICLE-REF", skippedPlate: "SKIP123", }); await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).toContainText("SKIP-VEHICLE-REF"); await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).not.toContainText("SKIP-BOOKING-A"); await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).not.toContainText("SKIP-BOOKING-B"); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("SKIP-VEHICLE-REF"); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 2_500 }).toBe(0); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 2_500 }).toBe(0); await waitForStepReset(page); }); test("reloading after explicitly selecting a booking keeps the choice and does not reopen the chooser", async ({ page, }) => { const fixture = createMultiBookingFixture({ reg: "RESTSEL1", bookings: [ buildMobileOrderBooking(8401, { reg_1: "RESTTRAC1", reg_2: "RESTSEL1", datetime: "2026-01-01T07:00:00.000Z", reference: "RESTORE-SELECT-A", reference_number: "RESTORE-SELECT-A", }), buildMobileOrderBooking(8402, { reg_1: "RESTTRAC2", reg_2: "RESTSEL1", datetime: "2026-01-01T09:00:00.000Z", reference: "RESTORE-SELECT-B", reference_number: "RESTORE-SELECT-B", }), ], }); await openMobileStep1FromStoredSnapshot( page, fixture, { reg: "RESTSEL1", }, { token: "mobile-restore-selected-booking-token", } ); await waitForOrderBookingPopup(page); await page.getByTestId("pos-mobile-order-booking-use-8402").click(); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null, reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "", reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: 8402, vehicleBookingId: 8402, reg1: "RESTTRAC2", reg2: "RESTSEL1", }); const orderBookingsGetBeforeReload = fixture.requestCounters.orderBookingsGet; await page.reload(); await expect .poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 }) .toBeGreaterThan(orderBookingsGetBeforeReload); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null, reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "", reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: 8402, vehicleBookingId: 8402, reg1: "RESTTRAC2", reg2: "RESTSEL1", }); await page.waitForTimeout(1_000); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0); }); test("reloading after explicitly skipping a booking keeps the skip decision and does not reopen the chooser", async ({ page, }) => { const fixture = createMultiBookingFixture({ reg: "RESTSKIP", bookings: [ buildMobileOrderBooking(8501, { reg_1: "RESTSKIP", datetime: "2026-01-01T07:00:00.000Z", reference: "RESTORE-SKIP-A", reference_number: "RESTORE-SKIP-A", }), buildMobileOrderBooking(8502, { reg_1: "RESTSKIP", datetime: "2026-01-01T09:00:00.000Z", reference: "RESTORE-SKIP-B", reference_number: "RESTORE-SKIP-B", }), ], }); await openMobileStep1FromStoredSnapshot( page, fixture, { reg: "RESTSKIP", }, { token: "mobile-restore-skipped-booking-token", } ); await waitForOrderBookingPopup(page); await page.getByTestId("pos-mobile-order-booking-skip").click(); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: null, skippedPlate: "RESTSKIP", }); const orderBookingsGetBeforeReload = fixture.requestCounters.orderBookingsGet; await page.reload(); await expect .poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 }) .toBeGreaterThan(orderBookingsGetBeforeReload); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "", }; }, { timeout: 10_000 } ) .toEqual({ bookingId: null, skippedPlate: "RESTSKIP", }); await page.waitForTimeout(1_000); await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0); }); test("required reference prompt retries and creates the order after confirmation", async ({ page }) => { const fixture = createMobilePosFixture({ customerAttributesByNumber: { [REGULAR_CUSTOMER_ID]: [ { id: 9, customer_number: REGULAR_CUSTOMER_ID, attribute: "requiresReferenceNumber", }, ], }, }); const createResult = await createOrderFromStep1(page, fixture, { reg: "FREE123", reference: "", requiredReference: "REQ-REF-001", extraSeedState: { reference: "", }, }); expect(createResult.confirmedRequiredReferencePrompt).toBe(true); await expect .poll(() => Number(new URL(page.url()).searchParams.get("id")) || 0, { timeout: 10_000 }) .toBeGreaterThan(0); const orderId = Number(new URL(page.url()).searchParams.get("id")); await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("REQ-REF-001"); expect(fixture.requestCounters.orderCreate).toBe(1); }); test("invalid mobile POS route params fall back to step 1", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-invalid-route-token", seedState: { customerId: null, reg: "FREE123", reference: "INVALID-ROUTE-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.goto( `/admin/${fixture.departmentId ?? DEFAULT_DEPARTMENT_ID}/modules/pos?step=999&id=abc&customer_id=def` ); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0); }); test("step 2 without an order id falls back to step 1", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-missing-order-id-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "FREE123", reference: "MISSING-ORDER-ID-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 1, }, }); await page.goto( `/admin/${fixture.departmentId ?? DEFAULT_DEPARTMENT_ID}/modules/pos?step=2&customer_id=${REGULAR_CUSTOMER_ID}` ); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0); const posSnapshot = await getStoredPosSnapshot(page); expect(posSnapshot?.metadata?.customerId ?? null).toBeNull(); expect(posSnapshot?.vehicles?.vehicle_1 ?? null).toBeNull(); }); test("step 1 attachments upload after order creation completes", async ({ page }) => { const fixture = createMobilePosFixture(); await createOrderFromStep1(page, fixture, { reg: "ATT1234", reference: "ATTACH-SUCCESS-REF", extraSeedState: { attachmentsBase64: [createAttachmentFile("damage-success.jpg")], }, }); await selectPrimaryProduct(page, 53); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.attachmentUpload, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestLog.attachmentUploads.length, { timeout: 10_000 }).toBe(1); await waitForStepReset(page); }); test("fresh step 1 clears stored pos_order_id without deleting a historical order", async ({ page }) => { const orderId = 9305; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "RESTORE-REF", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-stored-order-fresh-token", storedOrderId: orderId, seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "RESTORE-REF", includePrimaryItem: false, vehicleType: 53, }, route: { step: 1, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0); await expect.poll(() => page.evaluate(() => window.localStorage.getItem("pos_order_id"))).toBeNull(); expect(fixture.requestCounters.orderCreate).toBe(0); expect(fixture.requestCounters.orderGet).toBe(0); expect(fixture.requestCounters.orderDelete).toBe(0); expect(fixture.deletedOrderIds.includes(orderId)).toBe(false); }); test("returning from a route-loaded order to mobile step 1 clears stale order before clear all", async ({ page }) => { const orderId = 9405; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "STALE-ROUTE-REF", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-route-loaded-clear-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "STALE-ROUTE-REF", includePrimaryItem: true, primaryItemId: 53, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.evaluate((departmentId) => { window.history.pushState({}, "", `/admin/${departmentId}/modules/pos?step=1`); window.dispatchEvent(new PopStateEvent("popstate", { state: {} })); }, fixture.departmentId ?? DEFAULT_DEPARTMENT_ID); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0); const posSnapshot = await getStoredPosSnapshot(page); expect(posSnapshot?.metadata?.customerId ?? null).toBeNull(); expect(posSnapshot?.vehicles?.vehicle_1 ?? null).toBeNull(); expect(posSnapshot?.transactionItems?.primaryItem ?? null).toBeNull(); const clearAllAction = page .getByTestId("pos-mobile-clear-all-button") .or(page.locator('[data-action-key="pos-mobile-clear-all"]')) .or(page.getByText("Slet alt")) .first(); await expect(clearAllAction).toBeVisible({ timeout: 10_000 }); await clearAllAction.click({ force: true }); await page.waitForTimeout(500); expect(fixture.requestCounters.orderDelete).toBe(0); expect(fixture.deletedOrderIds.includes(orderId)).toBe(false); }); test("step 2 query bootstrap seeds the vehicle reference and auto-loads the primary product", async ({ page }) => { const orderId = 9401; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); const seedState = buildMobilePosState({ fixture, customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "", includePrimaryItem: false, vehicleType: 53, lastOrderId: null, }); seedState.metadata.reference = ""; seedState.vehicles.vehicle_1.reference = "REF-AB12345"; await setupMobilePosPage(page, fixture, { token: "mobile-step2-bootstrap-token", seedState, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345"); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(53); await page.getByTestId("pos-mobile-notes-input").scrollIntoViewIfNeeded(); await expectAboveFixedActions(page, page.getByTestId("pos-mobile-notes-input")); await page.getByTestId("pos-mobile-notes-input").fill("mobile-pos-notes"); await page.getByTestId("pos-mobile-reference-step-2-input").scrollIntoViewIfNeeded(); await expectAboveFixedActions(page, page.getByTestId("pos-mobile-reference-step-2-input")); await page.getByTestId("pos-mobile-reference-step-2-input").fill("mobile-pos-reference"); await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("mobile-pos-notes"); await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("mobile-pos-reference"); await expect .poll( () => fixture.requestLog.orderUpdates.some( (entry) => Number(entry?.id) === orderId && entry?.notes === "mobile-pos-notes" ), { timeout: 10_000 } ) .toBe(true); await expect .poll( () => fixture.requestLog.orderUpdates.some( (entry) => Number(entry?.id) === orderId && entry?.reference === "mobile-pos-reference" ), { timeout: 10_000 } ) .toBe(true); }); test("manual step 2 reference and product changes survive reload without being replaced by vehicle defaults", async ({ page, }) => { const orderId = 9404; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); const seedState = buildMobilePosState({ fixture, customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "", includePrimaryItem: false, vehicleType: 53, lastOrderId: null, }); seedState.metadata.reference = ""; seedState.vehicles.vehicle_1.reference = "REF-AB12345"; await setupMobilePosPage(page, fixture, { token: "mobile-step2-no-overwrite-token", seedState, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345"); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(53); await page.getByTestId("pos-mobile-reference-step-2-input").fill("MANUAL-STEP2-REF"); await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("MANUAL-STEP2-REF"); await openVehicleSelectionFromPrimaryProduct(page); await selectPrimaryProduct(page, 63); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(63); await page.evaluate(async () => { const flow = await import( "/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue" ); const currentVehicle = flow.vehicles.get(1); flow.vehicles.select(1, { ...(currentVehicle ?? {}), type: 53, reference: "REF-AB12345", }); }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("MANUAL-STEP2-REF"); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(63); }); test("dragging on the primary product card does not open vehicle selection", async ({ page }) => { const orderId = 9403; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "SCROLL-GUARD", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-scroll-guard-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "SCROLL-GUARD", includePrimaryItem: true, primaryItemId: 53, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const holdIndicator = page.getByTestId("pos-mobile-primary-product-hold-indicator"); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); await expect(holdIndicator).toHaveAttribute("data-state", "idle"); await dragAcrossPrimaryProduct(page); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); await expect(holdIndicator).toHaveAttribute("data-state", "idle"); await openVehicleSelectionFromPrimaryProduct(page); }); test("primary product hold indicator arms only for a stationary press", async ({ page }) => { const orderId = 9404; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "HOLD-INDICATOR", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-hold-indicator-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "HOLD-INDICATOR", includePrimaryItem: true, primaryItemId: 53, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const holdIndicator = page.getByTestId("pos-mobile-primary-product-hold-indicator"); const holdCountdown = page.getByTestId("pos-mobile-primary-product-hold-countdown"); const { trigger, clientX, clientY } = await getPrimaryProductTouchPoint(page); await expect(holdIndicator).toHaveAttribute("data-state", "idle"); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await trigger.dispatchEvent("pointermove", { pointerType: "touch", clientX, clientY: clientY - 40, }); await page.waitForTimeout(160); await expect(holdIndicator).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY: clientY - 40, }); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await page.waitForTimeout(160); await expect(holdIndicator).toHaveAttribute("data-state", "arming"); await expect(holdCountdown).toBeVisible(); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY, }); await expect(holdIndicator).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); }); test("copy last wash button arms only for a stationary press", async ({ page }) => { const orderId = 9409; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "COPY-HOLD-GUARD", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); fixture.orderItemsByOrderId[DEFAULT_LAST_ORDER_ID] = [ buildFixtureOrderItem( fixture, 53, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, }, 9501 ), buildFixtureOrderItem( fixture, 71, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 2, related_item_id: 9501, }, 9502 ), buildFixtureOrderItem( fixture, 41, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, related_item_id: null, }, 9503 ), ]; await setupMobilePosPage(page, fixture, { token: "mobile-copy-last-wash-hold-guard-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "COPY-HOLD-GUARD", includePrimaryItem: true, primaryItemId: 63, vehicleType: 63, lastOrderId: DEFAULT_LAST_ORDER_ID, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const copyLastWashButton = page.getByTestId("pos-mobile-copy-last-wash"); const holdCountdown = page.getByTestId("pos-mobile-copy-last-wash-countdown"); const { trigger, clientX, clientY } = await getCopyLastWashTouchPoint(page); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(copyLastWashButton).toContainText("Kopierer ydelse, tilvalg og ekstra varer fra sidste vask"); await expect(copyLastWashButton).toContainText("Hold inde for at kopiere sidste vask"); await expect(copyLastWashButton).toHaveAttribute("data-state", "idle"); await dragAcrossCopyLastWash(page); await expect(copyLastWashButton).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(63); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await page.waitForTimeout(160); await expect(copyLastWashButton).toHaveAttribute("data-state", "arming"); await expect(holdCountdown).toBeVisible(); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY, }); await expect(copyLastWashButton).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(63); }); test("copy last wash button indicates when the current selection already matches the previous wash", async ({ page, }) => { const orderId = 94111; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "COPY-LAST-WASH-MATCH", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); fixture.orderItemsByOrderId[DEFAULT_LAST_ORDER_ID] = [ buildFixtureOrderItem( fixture, 53, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, }, 9501 ), buildFixtureOrderItem( fixture, 71, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 2, related_item_id: 9501, }, 9502 ), buildFixtureOrderItem( fixture, 41, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, related_item_id: null, }, 9503 ), ]; const primaryProduct = JSON.parse( JSON.stringify(fixture.products.find((product) => Number(product.id) === 53) || null) ); const addonProduct = JSON.parse( JSON.stringify(fixture.products.find((product) => Number(product.id) === 71) || null) ); const additionalProduct = JSON.parse( JSON.stringify(fixture.products.find((product) => Number(product.id) === 41) || null) ); if (!primaryProduct || !addonProduct || !additionalProduct) { throw new Error("Required fixture products for the copy-last-wash match test are unavailable."); } primaryProduct.addons = [ { id: addonProduct.id, name: addonProduct.name, price: Number(addonProduct.price ?? 0), product: { ...addonProduct, addons: [], }, quantity: 2, min: 0, max: 3, }, ]; additionalProduct.quantity = 1; await setupMobilePosPage(page, fixture, { token: "mobile-copy-last-wash-match-token", seedState: buildMobilePosState({ fixture, customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "COPY-LAST-WASH-MATCH", includePrimaryItem: true, primaryItem: primaryProduct, vehicleType: 53, lastOrderId: DEFAULT_LAST_ORDER_ID, additionalItems: [additionalProduct], }), route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toContainText( "Samme ydelse, tilvalg og ekstra varer som sidste vask", { timeout: 10_000, } ); await expect(page.getByTestId("pos-mobile-copy-last-wash-matched")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-copy-last-wash-countdown")).toHaveCount(0); }); test("dragging on additional items does not open additional item selection", async ({ page }) => { const orderId = 9411; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "ADDITIONAL-ITEMS-SCROLL-GUARD", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-additional-items-scroll-guard-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "ADDITIONAL-ITEMS-SCROLL-GUARD", includePrimaryItem: true, primaryItemId: 53, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const additionalItemsButton = page.getByTestId("pos-mobile-additional-items-open"); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(additionalItemsButton).toHaveAttribute("data-state", "idle"); await expect(additionalItemsButton).toContainText("Hold inde for at vælge yderligere varer"); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); await dragAcrossAdditionalItems(page); await expect(additionalItemsButton).toHaveAttribute("data-state", "idle"); await expect(page.getByTestId("pos-mobile-additional-items-hold-countdown")).toHaveCount(0); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); }); test("additional items hold indicator arms only for a stationary press", async ({ page }) => { const orderId = 9412; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "ADDITIONAL-ITEMS-HOLD", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-additional-items-hold-indicator-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "ADDITIONAL-ITEMS-HOLD", includePrimaryItem: true, primaryItemId: 53, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const additionalItemsButton = page.getByTestId("pos-mobile-additional-items-open"); const holdCountdown = page.getByTestId("pos-mobile-additional-items-hold-countdown"); const { trigger, clientX, clientY } = await getAdditionalItemsTouchPoint(page); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(additionalItemsButton).toHaveAttribute("data-state", "idle"); const initialButtonBox = await additionalItemsButton.boundingBox(); expect(initialButtonBox).not.toBeNull(); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await trigger.dispatchEvent("pointermove", { pointerType: "touch", clientX, clientY: clientY - 40, }); await page.waitForTimeout(160); await expect(additionalItemsButton).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY: clientY - 40, }); await trigger.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY, }); await page.waitForTimeout(160); await expect(additionalItemsButton).toHaveAttribute("data-state", "arming"); await expect(holdCountdown).toBeVisible(); const armedButtonBox = await additionalItemsButton.boundingBox(); expect(armedButtonBox).not.toBeNull(); expect(Math.abs((armedButtonBox?.height ?? 0) - (initialButtonBox?.height ?? 0))).toBeLessThanOrEqual(1); await trigger.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY, }); await expect(additionalItemsButton).toHaveAttribute("data-state", "idle"); await expect(holdCountdown).toHaveCount(0); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); }); test("copy previous wash restores the last order primary service, addons, and standalone additional items", async ({ page, }) => { const orderId = 9410; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "COPY-LAST-WASH", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); fixture.orderItemsByOrderId[DEFAULT_LAST_ORDER_ID] = [ buildFixtureOrderItem( fixture, 53, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, }, 9501 ), buildFixtureOrderItem( fixture, 71, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 2, related_item_id: 9501, }, 9502 ), buildFixtureOrderItem( fixture, 41, { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, related_item_id: null, }, 9503 ), ]; await setupMobilePosPage(page, fixture, { token: "mobile-copy-last-wash-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "COPY-LAST-WASH", includePrimaryItem: true, primaryItemId: 63, vehicleType: 63, lastOrderId: DEFAULT_LAST_ORDER_ID, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-last-order")).toContainText("Sidste vask", { timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-last-order")).toContainText("Reference", { timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-last-order")).not.toContainText("COMMON.REFERENCE", { timeout: 10_000, }); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toContainText( "Kopierer ydelse, tilvalg og ekstra varer fra sidste vask", { timeout: 10_000, } ); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toContainText("Hold inde for at kopiere sidste vask", { timeout: 10_000, }); await longPressCopyLastWash(page); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); const primaryItem = snapshot?.transactionItems?.primaryItem ?? null; const addonSummary = (primaryItem?.addons || []) .map((addon) => ({ id: Number(addon?.product?.id ?? addon?.id ?? 0), quantity: Number(addon?.quantity ?? 0), })) .sort((left, right) => left.id - right.id); const additionalItems = (snapshot?.transactionItems?.additionalItems || []) .map((item) => ({ id: Number(item?.id ?? 0), quantity: Number(item?.quantity ?? 0), })) .sort((left, right) => left.id - right.id); return { primaryId: Number(primaryItem?.id ?? 0), addons: addonSummary, additionalItems, }; }, { timeout: 10_000 } ) .toEqual({ primaryId: 53, addons: [{ id: 71, quantity: 2 }], additionalItems: [{ id: 41, quantity: 1 }], }); await expect(page.getByTestId("pos-mobile-copy-last-wash")).toContainText( "Samme ydelse, tilvalg og ekstra varer som sidste vask", { timeout: 10_000, } ); await expect(page.getByTestId("pos-mobile-copy-last-wash-matched")).toBeVisible({ timeout: 10_000 }); }); test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => { const orderId = 9405; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "REG-POPUP-REF", reg_1: "AB12345", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-reg-popup-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "REG-POPUP-REF", includePrimaryItem: false, vehicleType: 53, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first().click(); await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 }); await expectPopupFooterAlignedToShell(page); await expectAboveFixedActions(page, page.getByTestId("pos-mobile-popup-footer")); await page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34"); await expect(page.getByTestId("pos-mobile-select-vehicle-header-close")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-select-vehicle-header-close").click(); await expect(page.getByTestId("pos-mobile-popup")).toBeHidden({ timeout: 10_000 }); await expect.poll(() => fixture.ordersById[orderId]?.reg_1 ?? "", { timeout: 10_000 }).toBe("CD1234"); await expect .poll(async () => (await getStoredPosSnapshot(page))?.vehicles?.vehicle_1?.reg ?? "", { timeout: 10_000 }) .toBe("CD1234"); await expect .poll( () => fixture.requestLog.orderUpdates.some((entry) => Number(entry?.id) === orderId && entry?.reg_1 === "CD-12 34"), { timeout: 10_000 } ) .toBe(true); await page.reload(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first()).toContainText( "CD1234" ); await expect .poll(async () => (await getStoredPosSnapshot(page))?.vehicles?.vehicle_1?.reg ?? "", { timeout: 10_000 }) .toBe("CD1234"); }); test("shows loading states while mobile step 2 categories and products resolve", async ({ page }) => { const orderId = 9401; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "STEP2-LOADING-REF", reg_1: "ZZ00000", }), }, orderItemsByOrderId: { [orderId]: [], }, departmentCategoriesDelayMs: 3_000, productsDelayMsByCategory: { 4: 3_000, 8: 3_000, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-loading-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "ZZ00000", reference: "STEP2-LOADING-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const categoryLoading = page.getByTestId("pos-mobile-categories-loading"); const productLoading = page.getByTestId("pos-mobile-products-loading"); await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 }); await expect(categoryLoading).toBeVisible({ timeout: 10_000 }); await expect(productLoading).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-category-4")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-4").click(); await expect(categoryLoading).toBeHidden({ timeout: 10_000 }); await expect(productLoading).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-product-53")).toBeVisible({ timeout: 10_000 }); await expect(productLoading).toBeHidden({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-8").click(); await expect(page.getByTestId("pos-mobile-product-91")).toBeVisible({ timeout: 10_000 }); await expect(productLoading).toBeHidden({ timeout: 10_000 }); }); test("manual step 2 selection supports addons and additional items", async ({ page }) => { const orderId = 9402; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "STEP2-MANUAL-REF", reg_1: "ZZ00000", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-manual-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "ZZ00000", reference: "STEP2-MANUAL-REF", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await selectPrimaryProduct(page, 53); const primaryProductCard = page.getByTestId("pos-mobile-primary-product-card"); await expect(primaryProductCard).toBeVisible({ timeout: 10_000 }); await expect(primaryProductCard.getByTestId("pos-mobile-additional-items-header-title")).toBeVisible({ timeout: 10_000, }); await expect(page.getByTestId("pos-mobile-additional-items")).toHaveCount(0); await expectSameRow( primaryProductCard.getByTestId("pos-mobile-additional-items-header-title"), primaryProductCard.getByTestId("pos-mobile-additional-items-header-icon") ); await expect(page.getByTestId("pos-mobile-addon-71")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-addon-71").click(); await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("1", { timeout: 10_000 }); await page.getByTestId("pos-mobile-addon-71-increase").click(); await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("2", { timeout: 10_000 }); await longPressAdditionalItems(page); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-8").click(); await page.waitForTimeout(2_000); await page.getByTestId("pos-mobile-product-91").click(); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return (snapshot?.transactionItems?.additionalItems || []) .map((item) => Number(item?.id)) .sort((left, right) => left - right); }, { timeout: 10_000 } ) .toEqual([91]); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(primaryProductCard).toContainText("Extra detergent", { timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("2", { timeout: 10_000 }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return (snapshot?.transactionItems?.additionalItems || []).map((item) => ({ id: Number(item?.id), quantity: Number(item?.quantity ?? 0), })); }, { timeout: 10_000 } ) .toEqual([ { id: 91, quantity: 1, }, ]); await page.waitForTimeout(500); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(3); await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(3); const createdProductIds = fixture.requestLog.orderItemCreates.map((entry) => Number(entry.product_id)); expect(createdProductIds).toEqual([53, 71, 91]); }); test("manual step 2 blocks additional items when customer restricts additional services", async ({ page }) => { const orderId = 9414; const fixture = createMobilePosFixture({ customerAttributesByNumber: { [REGULAR_CUSTOMER_ID]: [ { id: 941401, customer_number: REGULAR_CUSTOMER_ID, attribute: "restrictAdditionalServices", }, ], }, ordersById: { [orderId]: buildRegularOrder(orderId, { reference: "STEP2-RESTRICT-ADDITIONAL", reg_1: "ZZ00000", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-step2-restrict-additional-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "ZZ00000", reference: "STEP2-RESTRICT-ADDITIONAL", includePrimaryItem: false, vehicleType: null, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0); await selectPrimaryProduct(page, 53); await expect(page.getByTestId("pos-mobile-additional-items-open")).toHaveCount(1); await page.getByTestId("pos-mobile-additional-items-open").click(); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); await longPressAdditionalItems(page); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.additionalItems || []; }, { timeout: 10_000 } ) .toEqual([]); }); test("clear all deletes the order and returns to the scanner", async ({ page }) => { const orderId = 9403; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-clear-all-token", storedOrderId: orderId, seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "CLEAR-ALL-REF", includePrimaryItem: true, primaryItemId: 53, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-clear-all-button")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-clear-all-button").click({ force: true }); await expect.poll(() => fixture.deletedOrderIds.includes(orderId), { timeout: 10_000 }).toBe(true); await waitForStepReset(page); }); test("clear all requires typed confirmation before deleting a protected completed order", async ({ page }) => { const orderId = 9404; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [ { id: 9904, order_id: orderId, product_id: 53, product: fixtureProduct(53), quantity: 1, notes: "", reference: "", related_item_id: null, price: 599, }, ], }, attachmentsByOrderId: { [orderId]: [ { id: 4404, object_id: orderId, object_type: "orders", content: { document: "damage.pdf", other: "damage.pdf", }, created_at: "2026-01-01T10:10:00.000Z", updated_at: "2026-01-01T10:10:00.000Z", deleted_at: null, }, ], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-clear-all-protected-token", storedOrderId: orderId, seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "CLEAR-ALL-PROTECTED-REF", includePrimaryItem: true, primaryItemId: 53, attachmentsBase64: [], }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-clear-all-button")).toBeVisible({ timeout: 10_000 }); fixture.ordersById[orderId].completed_at = "2026-01-01T10:10:00.000Z"; await page.getByTestId("pos-mobile-clear-all-button").click({ force: true }); await expect.poll(() => fixture.requestCounters.orderDelete, { timeout: 10_000 }).toBe(1); expect(fixture.requestLog.orderDeletes[0]).toEqual({ id: orderId, confirmed: false }); expect(fixture.deletedOrderIds.includes(orderId)).toBe(false); await expect(page.locator(".swal2-title")).toContainText(`Bekræft sletning af ordre #${orderId}`, { timeout: 10_000, }); await expect(page.locator(".swal2-html-container")).toContainText("ordren er fuldført"); await expect(page.locator(".swal2-html-container")).toContainText("ordren har varelinjer"); await expect(page.locator(".swal2-html-container")).toContainText("ordren har vedhæftninger"); const confirmButton = page.locator(".swal2-confirm"); await expect(confirmButton).toBeDisabled(); await page.getByTestId("protected-order-delete-input").fill(String(orderId)); await expect(confirmButton).toBeEnabled(); expect(fixture.deletedOrderIds.includes(orderId)).toBe(false); await confirmButton.click(); await expect.poll(() => fixture.requestCounters.orderDelete, { timeout: 10_000 }).toBe(2); expect(fixture.requestLog.orderDeletes[1]).toEqual({ id: orderId, confirmed: true }); await expect.poll(() => fixture.deletedOrderIds.includes(orderId), { timeout: 10_000 }).toBe(true); await waitForStepReset(page); }); test("mobile POS actions expose stable metadata and clean localized labels", async ({ page }) => { const orderId = 9411; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [ { id: 9911, order_id: orderId, product_id: 53, product: fixtureProduct(53), quantity: 1, notes: "", reference: "", related_item_id: null, price: 599, }, ], }, }); await forceLocale(page, "en"); await setupMobilePosPage(page, fixture, { token: "mobile-action-metadata-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "ACTION-METADATA", includePrimaryItem: true, primaryItemId: 53, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); const completeButton = page.getByTestId("pos-mobile-next-step"); const clearAllButton = page.getByTestId("pos-mobile-clear-all-button"); await expect(completeButton).toBeVisible({ timeout: 10_000 }); await expect(clearAllButton).toBeVisible({ timeout: 10_000 }); await expect(completeButton).toHaveAttribute("data-action-key", "pos-mobile-complete-order"); await expect(clearAllButton).toHaveAttribute("data-action-key", "pos-mobile-clear-all"); await expect(completeButton).toHaveAttribute("data-copy-key", "complete"); await expect(clearAllButton).toHaveAttribute("data-copy-key", "clear_all"); await expect(completeButton).toContainText(/Complete|Afslut|Fuldf/); await expectCleanActionText(completeButton); await expectCleanActionText(clearAllButton); await gotoMobilePos(page, { departmentId: fixture.departmentId ?? DEFAULT_DEPARTMENT_ID, step: 1, }); const attachmentsToggle = page.getByTestId("pos-mobile-attachments-toggle"); await expect(attachmentsToggle).toBeVisible({ timeout: 10_000 }); await expect(attachmentsToggle).toHaveAttribute("data-action-key", "pos-mobile-attachments-toggle"); await expect(attachmentsToggle).toContainText("Attachments"); await expectCleanActionText(attachmentsToggle); await attachmentsToggle.click(); const expectedLabelsByActionKey = { "pos-mobile-attachment-view-take-picture": "Take picture", "pos-mobile-attachment-view-close": "Close", "pos-mobile-attachments-upload-file": "Upload", "pos-mobile-attachments-wash-certificate": "Wash certificate", }; for (const [actionKey, label] of Object.entries(expectedLabelsByActionKey)) { const action = getByActionKey(page, actionKey); await expect(action).toBeVisible({ timeout: 10_000 }); await expect(action).toHaveAttribute("data-action-key", actionKey); await expect(action).toContainText(label); await expectCleanActionText(action); } await expect(page.locator("body")).not.toContainText("admin.pos.wash_certificate"); }); test("mobile image viewer uses localized controls", async ({ page }) => { const imageAttachment = { filename: "damage.svg", base64String: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiBmaWxsPSJyZWQiLz48L3N2Zz4=", }; await forceLocale(page, "en"); await setupMobilePosPage(page, createMobilePosFixture(), { token: "mobile-image-viewer-locale-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", includePrimaryItem: false, attachmentsBase64: [imageAttachment], lastOrderId: null, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-step-1")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-attachments-toggle").click(); const thumbnail = page.getByAltText("damage.svg"); await expect(thumbnail).toBeVisible({ timeout: 10_000 }); await thumbnail.click(); await expect(page.getByTestId("pos-mobile-image-viewer-zoom-in")).toContainText("Zoom", { timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-image-viewer-zoom-out")).toContainText("Zoom out"); await expect(page.getByTestId("pos-mobile-image-viewer-reset")).toContainText("Reset"); await expect(page.getByTestId("pos-mobile-image-viewer-close")).toContainText("Close"); }); test("step 2 sync is idempotent when the order already matches the local transaction", async ({ page }) => { const orderId = 9404; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [ { id: 9901, order_id: orderId, product_id: 53, product: fixtureProduct(53), quantity: 1, notes: "", reference: "", related_item_id: null, price: 599, }, ], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-idempotent-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "IDEMPOTENT-REF", includePrimaryItem: true, primaryItemId: 53, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 2_500 }).toBe(0); await expect.poll(() => fixture.requestCounters.orderItemsDelete, { timeout: 2_500 }).toBe(0); await waitForStepReset(page); }); test("prompts for a required product note before completing mobile order items", async ({ page }) => { const orderId = 9415; const baseFixture = createMobilePosFixture(); const product27 = { id: 27, name: "Ekstraordinær pr. 10 min inkl. kemi", description: "Extraordinary service requiring an item note", price: 125, subscription_allowed: true, category: 8, piktogram: "27", apply_category_discount: false, requires_note: false, is_wash: false, display_in_booking_form: true, order_priority: 5, addons: [], }; const primaryProduct = { ...fixtureProduct(53), addons: [ ...fixtureProduct(53).addons, { id: product27.id, name: product27.name, price: product27.price, product: { ...product27 }, quantity: 1, min: 0, max: -1, }, ], }; const fixture = createMobilePosFixture({ products: baseFixture.products .map((product) => { if (Number(product.id) !== 53) { return product; } return primaryProduct; }) .concat(product27), ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-product-27-note-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "PRODUCT-27-NOTE", primaryItem: primaryProduct, vehicleType: 53, lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-addon-27-value")).toHaveText("1", { timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({ timeout: 10_000, }); await page.getByTestId("pos-mobile-product-note-input").fill("Cancelled note"); await page.getByTestId("pos-mobile-product-note-cancel").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 1_000 }).toBe(0); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({ timeout: 10_000, }); await page.getByTestId("pos-mobile-product-note-input").fill("Extra chemical treatment on left side"); await page.getByTestId("pos-mobile-product-note-confirm").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); const product27Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 27); expect(product27Create?.notes).toBe("Extra chemical treatment on left side"); await waitForStepReset(page); }); test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => { const orderId = 9405; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "BOOK123", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-hydration-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "BOOK123", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: DEFAULT_BOOKING_ID, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [71], }); await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("BOOKING-REF-8101"); await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("BOOKING-REF-8101"); await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("Booking notes from planner"); await expect.poll(() => fixture.ordersById[orderId]?.po ?? "").toBe("PO-8101"); }); test("non-wash booking falls back to the first wash product", async ({ page }) => { const orderId = 9406; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "NOWASH1", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-fallback-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "NOWASH1", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: 8102, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.transactionItems?.primaryItem?.id ?? null; }, { timeout: 10_000 } ) .toBe(53); }); test("booking completion without safety seal sends confirmation email and resets", async ({ page }) => { const orderId = 9407; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "BOOK123", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-complete-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "BOOK123", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: DEFAULT_BOOKING_ID, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [71], }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.bookingCompletionConfirmationEmail, { timeout: 10_000 }).toBe(1); expect(fixture.markCompletedOrderIds).toContain(orderId); await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed"); await expect .poll(() => fixture.requestLog.bookingCompletionConfirmationEmails[0] ?? null, { timeout: 10_000 }) .toMatchObject({ booking_id: DEFAULT_BOOKING_ID, order_id: orderId, customer_number: REGULAR_CUSTOMER_ID, recipient: "pos-mobile@example.com", safety_seal: "", has_safety_seal: false, }); await waitForStepReset(page); }); test("booking completion with safety seal sends confirmation email with seal", async ({ page }) => { const orderId = 9409; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "BOOK123", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-basket-seal-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "BOOK123", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: DEFAULT_BOOKING_ID, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [71], }); await longPressAdditionalItems(page); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-8").click(); await page.waitForTimeout(2_000); await page.getByTestId("pos-mobile-product-41").click(); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return (snapshot?.transactionItems?.additionalItems || []) .map((item) => Number(item?.id)) .sort((left, right) => left - right); }, { timeout: 10_000 } ) .toEqual([41]); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.waitForTimeout(500); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-booking-safety-seal-input").fill("9090"); await page.getByTestId("pos-mobile-booking-complete-with-certificate").click(); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.bookingCompletionConfirmationEmail, { timeout: 10_000 }).toBe(1); expect(fixture.markCompletedOrderIds).toContain(orderId); await expect .poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 }) .toBe(9090); await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed"); await expect.poll(() => String(fixture.bookingsById[DEFAULT_BOOKING_ID]?.safety_seal ?? "")).toBe("9090"); await expect .poll(() => fixture.requestLog.bookingCompletionConfirmationEmails[0] ?? null, { timeout: 10_000 }) .toMatchObject({ booking_id: DEFAULT_BOOKING_ID, order_id: orderId, customer_number: REGULAR_CUSTOMER_ID, recipient: "pos-mobile@example.com", safety_seal: "9090", has_safety_seal: true, }); await waitForStepReset(page); }); test("booking completion with safety seal popup submits the explicit seal", async ({ page }) => { const orderId = 9408; const fixture = createMobilePosFixture({ bookingCompleteDelayMs: 750, ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "SEAL123", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-seal-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "SEAL123", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: 8103, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [41], }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-booking-safety-seal-input").fill("4242"); const withoutCertificateButton = page.getByTestId("pos-mobile-booking-complete-without-certificate"); const withCertificateButton = page.getByTestId("pos-mobile-booking-complete-with-certificate"); await withCertificateButton.click(); await expect(withCertificateButton).toHaveClass(/is-loading/); await expect(withoutCertificateButton).not.toHaveClass(/is-loading/); await expect(withoutCertificateButton).toBeDisabled(); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect .poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 }) .toBe(4242); await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="completed_transaction"]')).toBeVisible({ timeout: 10_000, }); await expect.poll(() => fixture.bookingsById[8103]?.status ?? "").toBe("completed"); await expect.poll(() => String(fixture.bookingsById[8103]?.safety_seal ?? "")).toBe("4242"); await expect .poll(() => (fixture.attachmentsByOrderId[orderId] || []).some( (attachment) => String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE" ) ) .toBe(true); await waitForStepReset(page); }); test("booking completion reuses the step 2 safety seal without opening the completion popup", async ({ page }) => { const orderId = 9412; const fixture = createMobilePosFixture({ ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "SEAL321", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-prefilled-seal-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "SEAL321", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: 8103, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [41], }); const safetySealInput = page.getByTestId("pos-mobile-safety-seal-step-2-input"); await expect(safetySealInput).toBeVisible({ timeout: 10_000 }); await safetySealInput.fill("5150"); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="complete_booking"]')).toHaveCount(0); await expect .poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 }) .toBe(5150); await expect.poll(() => fixture.bookingsById[8103]?.status ?? "").toBe("completed"); await expect.poll(() => String(fixture.bookingsById[8103]?.safety_seal ?? "")).toBe("5150"); await waitForStepReset(page); }); test("booking completion failure keeps the mobile booking popup open for retry", async ({ page }) => { const orderId = 9410; const fixture = createMobilePosFixture({ failureBudget: { bookingComplete: 1, }, ordersById: { [orderId]: buildRegularOrder(orderId, { reg_1: "SEAL123", reference: "", }), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-booking-failure-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "SEAL123", reference: "", includePrimaryItem: false, vehicleType: null, bookingId: 8103, vehicleStatus: "booked", lastOrderId: null, }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForBookingHydration(page, { primaryId: 53, addonProductIds: [41], }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-booking-safety-seal-input").fill("1111"); await page.getByTestId("pos-mobile-booking-complete-with-certificate").click(); await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-1-shell")).toHaveCount(0); await expect.poll(() => fixture.bookingsById[8103]?.status ?? "").toBe("pending"); }); test("order creation failure leaves the flow on step 1", async ({ page }) => { const fixture = createMobilePosFixture({ failureBudget: { orderCreate: 1, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-order-create-failure-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "FAIL-CREATE-REF", includePrimaryItem: false, vehicleType: 53, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-next-step")).toContainText("Pleno Logistics", { timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderCreate, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0); }); test("attachment upload failure is recorded but does not block completion", async ({ page }) => { const orderId = 9409; const fixture = createMobilePosFixture({ failureBudget: { attachmentUpload: 1, }, ordersById: { [orderId]: buildRegularOrder(orderId), }, orderItemsByOrderId: { [orderId]: [], }, }); await setupMobilePosPage(page, fixture, { token: "mobile-attachment-failure-token", seedState: { customerId: REGULAR_CUSTOMER_ID, reg: "AB12345", reference: "ATTACHMENT-REF", includePrimaryItem: true, primaryItemId: 53, attachmentsBase64: [createAttachmentFile("damage.jpg")], }, route: { step: 2, orderId, customerId: REGULAR_CUSTOMER_ID, }, }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.attachmentUpload, { timeout: 10_000 }).toBe(1); await waitForStepReset(page); }); test("shows the safety seal field for mobile baskets with a wash certificate and completes via order completion", async ({ page, }) => { const fixture = createMobilePosFixture(); await createOrderFromStep1(page, fixture, { reg: "AB12345", reference: "SEAL-REF", customerNumber: REGULAR_CUSTOMER_ID, extraSeedState: { bookingId: null, bookingMatches: [], }, }); const orderId = Number(new URL(page.url()).searchParams.get("id")); expect(orderId).toBeGreaterThan(0); await longPressAdditionalItems(page); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-category-8").click(); await page.waitForTimeout(2_000); await page.getByTestId("pos-mobile-product-41").click(); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); const safetySealInput = page.getByTestId("pos-mobile-safety-seal-step-2-input"); await expect(safetySealInput).toBeVisible({ timeout: 10_000 }); await safetySealInput.fill("SEAL-9001"); await safetySealInput.press("Tab"); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); expect(fixture.requestCounters.bookingComplete).toBe(0); await expect(fixture.markCompletedOrderIds).toContain(orderId); await expect.poll(() => fixture.ordersById[orderId]?.safety_seal ?? "", { timeout: 10_000 }).toBe("SEAL-9001"); await expect .poll( () => Boolean( fixture.attachmentsByOrderId[orderId]?.some( (attachment) => String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE" ) ), { timeout: 10_000 } ) .toBe(true); }); }); function fixtureProduct(productId) { const fixture = createMobilePosFixture(); return fixture.products.find((product) => product.id === productId); }