Files
pleno-vue/tests/e2e/pos-mobile-order-flow.spec.js
T
Jeppe Bundgaard c14f0cc1c4 Add utilities and tests for mobile order management and Subuser navigation:
- Introduced `mobileOrderCompletion.js` for mobile order metadata handling with safety seal syncing, metadata persistence, and booking linking utilities.
- Added tests for Subuser navigation menu (`navigation-menu-items-user-subusers.spec.js`) to validate links and permission gating.
- Created unit and end-to-end tests for Subuser grant permissions and management, including permission nodes and invites (`subuser-grant-permission-nodes.spec.js`, `subuser-management.spec.ts`).
- Implemented `SubuserCompleteRegistrationPage.vue` with validation, token handling, and registration flow for Subuser setup.
- Enhanced Playwright e2e tests for Subuser features (`subuserCompleteRegistration.spec.ts`).
2026-04-14 10:09:10 +02:00

1948 lines
63 KiB
JavaScript

import { expect, test } from "@playwright/test";
import {
DEFAULT_BOOKING_ID,
DEFAULT_DEPARTMENT_ID,
REGULAR_CUSTOMER_ID,
buildMobilePosState,
createAttachmentFile,
createMobilePosFixture,
gotoMobilePos,
getStoredPosSnapshot,
setupMobilePosPage,
suppressVueDevtoolsOverlay,
waitForMobileNextStepCooldown,
} from "./support/mobilePos.js";
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 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 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 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 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 namedProduct = page.getByText(productName, { exact: true });
const fallbackCard = page.getByTestId("pos-mobile-primary-product-card");
const hasNamedProduct = await namedProduct
.isVisible()
.then(() => true)
.catch(() => false);
const trigger = hasNamedProduct ? namedProduct : fallbackCard;
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.dispatchEvent("pointerdown");
await page.waitForTimeout(650);
await trigger.dispatchEvent("pointerup");
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
}
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 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 createOrderFromStep1(
page,
fixture,
{
reg = "FREE123",
reference = "STEP1-REF",
manualInput = false,
customerNumber = REGULAR_CUSTOMER_ID,
extraSeedState = {},
} = {}
) {
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) {
await waitForMobileStepTwoReady(page);
return;
}
await selectCustomerFromPopup(page, customerNumber);
await waitForMobileNextStepCooldown(page);
await page.getByTestId("pos-mobile-next-step").click();
await waitForMobileStepTwoReady(page);
}
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 });
});
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.getByText("Tilføj note", { exact: true }).click();
await expect(page.locator("#input_field")).toBeVisible({ timeout: 10_000 });
await expect(page.locator("#input_field")).toHaveAttribute("placeholder", "Tilføj note...");
});
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 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);
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await waitForStepReset(page);
});
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 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("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 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.locator('[data-testid="pos-mobile-popup"] .card-content');
await expect(page.getByTestId("pos-mobile-order-booking-skip")).toBeVisible({ timeout: 10_000 });
await expect
.poll(async () => popupContent.evaluate((element) => element.scrollHeight > element.clientHeight), {
timeout: 10_000,
})
.toBe(true);
await popupContent.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
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 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,
skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "",
};
},
{ timeout: 10_000 }
)
.toEqual({
bookingId: null,
skippedPlate: "SKIP123",
});
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",
},
],
},
});
await createOrderFromStep1(page, fixture, {
reg: "FREE123",
reference: "",
extraSeedState: {
reference: "",
},
});
await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 });
await page.locator(".swal2-input").fill("REQ-REF-001");
await page.locator(".swal2-confirm").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
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-next-step")).toContainText("Pleno Logistics", { timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0);
});
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("stored pos_order_id restores step 2 without creating a duplicate 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-restore-token",
storedOrderId: orderId,
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "RESTORE-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(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
expect(fixture.requestCounters.orderCreate).toBe(0);
expect(fixture.requestCounters.orderGet).toBeGreaterThan(0);
});
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("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 page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34");
await page.getByTestId("pos-mobile-popup").locator(".card-footer-item").last().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("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);
await expect(page.getByTestId("pos-mobile-additional-items-header-title")).toBeVisible({ timeout: 10_000 });
await expectSameRow(
page.getByTestId("pos-mobile-additional-items-header-title"),
page.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 page.getByTestId("pos-mobile-additional-items-open").click();
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(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("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",
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("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("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 completes 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.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
await waitForStepReset(page);
});
test("booking completion prompts when a safety seal is added to the basket", 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 page.getByTestId("pos-mobile-additional-items-open").click();
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.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
await expect.poll(() => String(fixture.bookingsById[DEFAULT_BOOKING_ID]?.safety_seal ?? "")).toBe("9090");
await waitForStepReset(page);
});
test("booking completion with safety seal popup submits the explicit seal", async ({ page }) => {
const orderId = 9408;
const fixture = createMobilePosFixture({
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");
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.bookingsById[8103]?.status ?? "").toBe("completed");
await expect.poll(() => String(fixture.bookingsById[8103]?.safety_seal ?? "")).toBe("4242");
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: "SEAL123",
reference: "SEAL-REF",
customerNumber: REGULAR_CUSTOMER_ID,
});
const orderId = Number(new URL(page.url()).searchParams.get("id"));
expect(orderId).toBeGreaterThan(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);
await expect(fixture.markCompletedOrderIds).toContain(orderId);
await expect
.poll(() => fixture.ordersById[orderId]?.safety_seal ?? "", { timeout: 10_000 })
.toBe("SEAL-9001");
await expect
.poll(
() =>
Boolean(
fixture.attachmentsByOrderId[orderId]?.some(
(attachment) => String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE"
)
),
{ timeout: 10_000 }
)
.toBe(true);
});
});
function fixtureProduct(productId) {
const fixture = createMobilePosFixture();
return fixture.products.find((product) => product.id === productId);
}