- Replaced repetitive department module URL logic with `buildDepartmentModulePath` utility in `DepartmentModulesDisplay.vue`. - Enhanced layouts and responsive behavior across components, including `PosDepartmentStep1MobileTransactionHistory.vue` and `DepartmentDailyReport.vue`. - Removed unused `SuperuserInvoicingLocalStore.vue` and refactored to `SuperuserInvoicingLocalStore.ts`. - Updated media query handling and card layout adjustments in `DepartmentDailyReport.vue`. - Added new Playwright configurations (`playwright.prod.config.ts` and `playwright.live.config.ts`) for e2e release workflows. - Extended e2e and unit test coverage for mobile POS and department modules.
1033 lines
35 KiB
JavaScript
1033 lines
35 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import {
|
|
DEFAULT_BOOKING_ID,
|
|
DEFAULT_DEPARTMENT_ID,
|
|
REGULAR_CUSTOMER_ID,
|
|
createAttachmentFile,
|
|
createMobilePosFixture,
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 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 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();
|
|
await selectCustomerFromPopup(page, customerNumber);
|
|
await page.getByTestId("pos-mobile-next-step").click();
|
|
}
|
|
|
|
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 fixture = createMobilePosFixture({
|
|
ordersById: {
|
|
9201: buildRegularOrder(9201, {
|
|
customer_name: "VOGNMAND JIMMY CHRISTENSEN ApS",
|
|
total_net_amount: 746,
|
|
pending_handheld: true,
|
|
created_at: "2026-04-09T12:08:00.000Z",
|
|
}),
|
|
9202: buildRegularOrder(9202, {
|
|
customer_name: "BYGMA Roskilde A/S",
|
|
total_net_amount: 507,
|
|
pending_handheld: true,
|
|
created_at: "2026-04-09T11:51:00.000Z",
|
|
}),
|
|
9203: buildRegularOrder(9203, {
|
|
customer_name: "VOLVO ENTREPRENØRMASKINER A/S",
|
|
total_net_amount: 512,
|
|
pending_handheld: true,
|
|
created_at: "2026-04-09T10: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("@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("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 auto-loads the primary product and persists notes/reference", async ({ page }) => {
|
|
const orderId = 9401;
|
|
const fixture = createMobilePosFixture({
|
|
ordersById: {
|
|
[orderId]: buildRegularOrder(orderId, {
|
|
reference: "BOOTSTRAP-REF",
|
|
reg_1: "AB12345",
|
|
}),
|
|
},
|
|
orderItemsByOrderId: {
|
|
[orderId]: [],
|
|
},
|
|
});
|
|
|
|
await setupMobilePosPage(page, fixture, {
|
|
token: "mobile-step2-bootstrap-token",
|
|
seedState: {
|
|
customerId: REGULAR_CUSTOMER_ID,
|
|
reg: "AB12345",
|
|
reference: "BOOTSTRAP-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 expect(page.getByTestId("pos-mobile-last-order")).toBeVisible({ timeout: 10_000 });
|
|
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-notes-input").press("Tab");
|
|
|
|
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 page.getByTestId("pos-mobile-reference-step-2-input").press("Tab");
|
|
|
|
await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("mobile-pos-notes");
|
|
await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("mobile-pos-reference");
|
|
});
|
|
|
|
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.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);
|
|
});
|
|
});
|
|
|
|
function fixtureProduct(productId) {
|
|
const fixture = createMobilePosFixture();
|
|
return fixture.products.find((product) => product.id === productId);
|
|
}
|