Files
pleno-vue/tests/e2e/pos-mobile-order-flow.spec.js
T
bugfix-bot 609ca5f917 fix(test): update E2E assertions for TRU-81 wording
The TRU-81 change replaced the terse 'tilladt' wording with clearer
'tilgængelige/t' phrasing in the restriction toasts and tooltips. The
E2E specs in pos-customer-rules.spec.js and pos-mobile-order-flow.spec.js
were still asserting the old 'tilladt for denne kunde' substring,
causing the 'changed-1-of-2-chromium-desktop' and
'changed-2-of-2-chromium-mobile' E2E PR checks to fail.

Update the assertions to match the new wording.
2026-08-17 13:43:19 +00:00

4885 lines
164 KiB
JavaScript

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 expectSameRenderedWidth(left, right, tolerance = 1) {
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await expect
.poll(async () => {
const [leftBox, rightBox] = await Promise.all([left.boundingBox(), right.boundingBox()]);
if (!leftBox || !rightBox) {
return Number.POSITIVE_INFINITY;
}
return Math.abs(leftBox.width - rightBox.width);
})
.toBeLessThanOrEqual(tolerance);
}
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,
price: 111,
},
9501
),
buildFixtureOrderItem(
fixture,
71,
{
order_id: DEFAULT_LAST_ORDER_ID,
quantity: 2,
related_item_id: 9501,
price: 112,
},
9502
),
buildFixtureOrderItem(
fixture,
41,
{
order_id: DEFAULT_LAST_ORDER_ID,
quantity: 1,
related_item_id: null,
price: 113,
},
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 });
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);
expect(
fixture.requestLog.orderItemCreates.every((payload) => !Object.prototype.hasOwnProperty.call(payload, "price"))
).toBe(true);
expect(
(fixture.orderItemsByOrderId[orderId] || [])
.map((item) => [Number(item.product_id), Number(item.price)])
.sort(([leftProductId], [rightProductId]) => leftProductId - rightProductId)
).toEqual([
[41, 25],
[53, 599],
[71, 99],
]);
});
test("copy previous wash keeps related add-ons and skips standalone additional services", async ({ page }) => {
const orderId = 9416;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
[REGULAR_CUSTOMER_ID]: [
{
id: 941601,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "restrictAdditionalServices",
product_restriction: {
version: 1,
disabled_product_ids: [91],
collections: [{ id: 94161, name: "Standalone services", product_ids: [91] }],
},
},
],
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "COPY-LAST-WASH-RESTRICTED",
reg_1: "AB12345",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
fixture.orderItemsByOrderId[DEFAULT_LAST_ORDER_ID] = [
buildFixtureOrderItem(
fixture,
53,
{
order_id: DEFAULT_LAST_ORDER_ID,
quantity: 1,
},
9511
),
buildFixtureOrderItem(
fixture,
71,
{
order_id: DEFAULT_LAST_ORDER_ID,
quantity: 2,
related_item_id: 9511,
},
9512
),
buildFixtureOrderItem(
fixture,
91,
{
order_id: DEFAULT_LAST_ORDER_ID,
quantity: 1,
related_item_id: null,
},
9513
),
];
await setupMobilePosPage(page, fixture, {
token: "mobile-copy-last-wash-restricted-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "COPY-LAST-WASH-RESTRICTED",
includePrimaryItem: true,
primaryItemId: 63,
vehicleType: 63,
lastOrderId: DEFAULT_LAST_ORDER_ID,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0);
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 longPressCopyLastWash(page);
await expect(page.getByTestId("pos-mobile-restriction-warning")).toContainText(
"Begrænsede tilvalg er fjernet fra kurven",
{
timeout: 10_000,
}
);
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 ?? addon?.product?.quantity ?? 0),
restricted: addon?.restricted === true,
}))
.sort((left, right) => left.id - right.id);
const additionalItems = (snapshot?.transactionItems?.additionalItems || [])
.map((item) => Number(item?.id ?? 0))
.sort((left, right) => left - right);
return {
primaryId: Number(primaryItem?.id ?? 0),
addons: addonSummary,
additionalItems,
};
},
{ timeout: 10_000 }
)
.toEqual({
primaryId: 53,
addons: [
{
id: 71,
quantity: 2,
restricted: false,
},
],
additionalItems: [],
});
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
const primaryCreate = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 53);
const addonCreate = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 71);
expect(primaryCreate?.related_item_id ?? null).toBeNull();
expect(Number(addonCreate?.related_item_id ?? 0)).toBeGreaterThan(0);
expect(fixture.requestLog.orderItemCreates.some((entry) => Number(entry.product_id) === 91)).toBe(false);
});
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 persists every selected primary product add-on (multi-addon happy path)", async ({ page }) => {
// Regression test for the bug where only some of the selected primary
// product add-ons were persisted to the order during mobile POS step 2.
// We select two add-ons on the primary and one standalone additional
// item, click Fuldfør, and assert that all four POST /order/items calls
// landed and that the server-side rows are linked correctly.
const orderId = 9426;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-MULTI-ADDON-REF",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-multi-addon-happy-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-MULTI-ADDON-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await selectPrimaryProduct(page, 53);
// Addons 71 and 41 are both attached to fixture product 53.
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 expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-41").click();
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
// One standalone additional item (id 91) so we also exercise the
// additionalItems path alongside the add-ons.
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]);
// First click closes the additional-items fullscreen view (the
// fullscreen "Next" button's customAction toggles additionalItemSelection
// from true -> false via onClickAddOtherProduct). The second click lands
// on the main step 2 layout's Complete button and triggers
// syncCurrentTransactionToOrder via onBeforeComplete.
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
// 1 primary + 2 addons + 1 additional = 4 POST /order/items calls.
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(4);
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(4);
const createdProductIds = fixture.requestLog.orderItemCreates.map((entry) => Number(entry.product_id));
// The order of POSTs is: primary, then add-ons (in fixture order), then
// additional items. Add-ons 71 and 41 are listed before the additional
// item 91.
expect(createdProductIds).toEqual([53, 71, 41, 91]);
// The add-on rows must be linked to the primary by related_item_id.
const persistedItems = fixture.orderItemsByOrderId[orderId] || [];
const primary = persistedItems.find((item) => item.product_id === 53 && item.related_item_id === null);
expect(primary).toBeTruthy();
const addon71 = persistedItems.find((item) => item.product_id === 71);
const addon41 = persistedItems.find((item) => item.product_id === 41);
expect(addon71?.related_item_id).toBe(primary.id);
expect(addon41?.related_item_id).toBe(primary.id);
// The standalone additional item must NOT be linked to the primary.
const additional91 = persistedItems.find((item) => item.product_id === 91);
expect(additional91?.related_item_id).toBeNull();
});
test("mobile POS rolls back partial sync when one primary add-on POST fails", async ({ page }) => {
// Regression test for the bug where a single add-on POST rejection left
// the order with the primary and a subset of add-ons on the server while
// the operator only saw a generic failure popup. The sync helper now
// collects per-product failures via Promise.allSettled and rolls back
// every order_items row it created during this attempt.
const orderId = 9427;
const fixture = createMobilePosFixture({
failureBudget: {
orderItemCreateForProductId: { 41: 1 },
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-PARTIAL-ROLLBACK-REF",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-partial-rollback-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-PARTIAL-ROLLBACK-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await selectPrimaryProduct(page, 53);
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 expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-41").click();
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
// The error popup should appear with the per-product failure message.
const errorPopup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="error"]');
await expect(errorPopup).toBeVisible({ timeout: 10_000 });
await expect(errorPopup).toContainText("Product 41 rejected");
// The failure budget forced exactly one rejection on add-on 41.
expect(fixture.failureBudget.orderItemCreateForProductId[41]).toBe(0);
// Rollback runs after the partial failure: every order_items row that
// landed in this attempt must have been deleted, so the order ends
// up empty (no half-synced state) and ready for a clean retry.
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(0);
await expect.poll(() => fixture.requestCounters.orderItemsDelete, { timeout: 10_000 }).toBeGreaterThanOrEqual(2);
// The order is still open (not marked completed) because the failure
// happened before completion could run.
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
});
test("manual step 2 disables exact add-on and standalone product controls with tap tooltips", async ({ page }) => {
const orderId = 9414;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
[REGULAR_CUSTOMER_ID]: [
{
id: 941401,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "restrictAdditionalServices",
product_restriction: {
version: 1,
disabled_product_ids: [71, 91],
collections: [{ id: 94141, name: "Blocked mobile products", product_ids: [71, 91] }],
},
},
],
},
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);
const addonRow = page.getByTestId("pos-mobile-addon-71");
const allowedAddonRow = page.getByTestId("pos-mobile-addon-41");
await expect(addonRow).toBeVisible({ timeout: 10_000 });
await expect(addonRow).toBeDisabled();
await expect(allowedAddonRow).toBeEnabled();
await expectSameRenderedWidth(addonRow, allowedAddonRow);
await expect(page.getByTestId("pos-mobile-addon-71-decrease")).toBeDisabled();
await expect(page.getByTestId("pos-mobile-addon-71-increase")).toBeDisabled();
await page.getByTestId("pos-mobile-addon-71-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilgængelige for denne kunde"
);
for (const testId of ["pos-mobile-addon-71-decrease", "pos-mobile-addon-71", "pos-mobile-addon-71-increase"]) {
await page.getByTestId(testId).evaluate((button) => button.click());
}
expect(fixture.requestCounters.orderItemsPost).toBe(0);
await longPressAdditionalItems(page);
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 });
const restrictedAdditionalItem = page.getByTestId("pos-mobile-product-91");
await expect(restrictedAdditionalItem).toBeVisible({ timeout: 10_000 });
await expect(restrictedAdditionalItem).toBeDisabled();
await page.getByTestId("pos-mobile-product-91-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilgængelige for denne kunde"
);
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
const addons = (snapshot?.transactionItems?.primaryItem?.addons || []).map((addon) => ({
id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? addon?.product?.quantity ?? 0),
}));
const additionalItems = snapshot?.transactionItems?.additionalItems || [];
return {
addon71Quantity: addons.find((addon) => addon.id === 71)?.quantity ?? null,
additionalItems,
};
},
{ timeout: 10_000 }
)
.toEqual({
addon71Quantity: 0,
additionalItems: [],
});
});
test("exact primary-product restriction blocks mobile selection while an unconfigured lookalike remains enabled", async ({
page,
}) => {
const orderId = 9417;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
[REGULAR_CUSTOMER_ID]: [
{
id: 941701,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "onlyTankCleaning",
product_restriction: {
version: 1,
disabled_product_ids: [53],
collections: [{ id: 94171, name: "Blocked primary products", product_ids: [53] }],
},
},
],
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-RESTRICT-PRIMARY",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-restrict-primary-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-RESTRICT-PRIMARY",
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 expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-4").click();
await page.waitForTimeout(800);
const blockedPrimary = page.getByTestId("pos-mobile-product-53");
await expect(blockedPrimary).toBeDisabled();
await page.getByTestId("pos-mobile-product-53-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Produktet er ikke tilgængeligt for denne kunde"
);
await blockedPrimary.evaluate((button) => button.click());
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible();
expect(fixture.requestCounters.orderItemsPost).toBe(0);
const allowedPrimary = page.getByTestId("pos-mobile-product-63");
await expect(allowedPrimary).toBeEnabled();
await allowedPrimary.click();
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: 10_000 }).toBe(1);
expect(Number(fixture.requestLog.orderItemCreates[0]?.product_id)).toBe(63);
});
test("customer-rule load failures keep mobile product mutations disabled until retry succeeds", async ({ page }) => {
const orderId = 9418;
const fixture = createMobilePosFixture({
failureBudget: {
customerAttributesGet: 2,
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-RULE-RETRY",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-rule-retry-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-RULE-RETRY",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-customer-restrictions-load-error")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-4").click();
await page.waitForTimeout(800);
await expect(page.getByTestId("pos-mobile-product-53")).toBeDisabled();
expect(fixture.requestCounters.orderItemsPost).toBe(0);
await page.getByTestId("pos-mobile-customer-restrictions-retry").click();
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(1);
await expect(page.getByTestId("pos-mobile-customer-restrictions-load-error")).toHaveCount(0);
await expect(page.getByTestId("pos-mobile-product-53")).toBeEnabled();
});
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");
expect(product27Create?.reason_code).toBe("customer_approved_extra_work");
expect(product27Create?.reason_comment).toBe("Extra chemical treatment on left side");
await waitForStepReset(page);
});
test("prompts for a required reason note for audited add-on products that are not the chemistry product", async ({
page,
}) => {
const orderId = 9416;
const baseFixture = createMobilePosFixture();
const auditedProduct = {
id: 25,
name: "Fælg flex pr. enhed",
description: "Audited addon that requires a reason comment",
price: 95,
subscription_allowed: true,
category: 8,
piktogram: "25",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 6,
addons: [],
};
const primaryProduct = {
...fixtureProduct(53),
addons: [
...fixtureProduct(53).addons,
{
id: auditedProduct.id,
name: auditedProduct.name,
price: auditedProduct.price,
product: { ...auditedProduct },
quantity: 1,
min: 0,
max: -1,
},
],
};
const fixture = createMobilePosFixture({
products: baseFixture.products
.map((product) => {
if (Number(product.id) !== 53) {
return product;
}
return primaryProduct;
})
.concat(auditedProduct),
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-product-25-note-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "PRODUCT-25-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-25-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("Højtryk bagpå venstre 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 product25Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 25);
expect(product25Create?.notes).toBe("Højtryk bagpå venstre side");
expect(product25Create?.reason_code).toBe("customer_approved_extra_work");
expect(product25Create?.reason_comment).toBe("Højtryk bagpå venstre 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);
});
test("surfaces a backend rejection as an error popup instead of failing silently", async ({ page }) => {
// Regression test: when syncCurrentTransactionToOrder hits an API rejection
// (e.g. a 400 from POST /order/items that the frontend can't recover from
// automatically), the operator used to see the Fuldfør button do nothing.
// The catch block in PosDepartmentStepMobileButtonNextStep now opens the
// standard error popup so the rejection is visible.
const orderId = 9421;
const fixture = createMobilePosFixture({
failureBudget: {
orderItemCreate: 1,
},
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-order-item-error-popup-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "ERR-POPUP-REF",
primaryItem: fixtureProduct(53),
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 page.getByTestId("pos-mobile-next-step").click();
const errorPopup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="error"]');
await expect(errorPopup).toBeVisible({ timeout: 10_000 });
await expect(errorPopup).toContainText("Notes is required for this product");
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible();
});
});
function fixtureProduct(productId) {
const fixture = createMobilePosFixture();
return fixture.products.find((product) => product.id === productId);
}