Files
pleno-vue/tests/e2e/userBookWash.spec.ts
T
Jeppe B a0c11e4bb7 Fix POS release-blocking customer rules and completion (#208)
Resolve direct-order product reconciliation context, preserve valid selections when restricted products are activated, remove the redundant certificate request, and add focused regression coverage.
2026-07-21 15:42:50 +02:00

435 lines
18 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { bookingTestData } from "./fixtures";
import {
completeBookingCreationFlow,
fillBookingField,
goToBookingProductSelectionStep as _goToBookingProductSelectionStep,
goToBookingProductSelectionStepWithOptions,
selectBookingDateTime,
} from "./support/bookingFlow";
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
import { isCompactProject } from "./support/projects";
async function prepareBookingPage(
page: Page,
pos: true | Record<string, unknown> = true,
sessionData: Record<string, unknown> = {}
) {
const apiMock = await mockApi(page, {
authenticated: true,
sessionData: {
display_name: "Booking User",
customer_number: 12345679,
permissions: ["user"],
...sessionData,
},
pos,
});
await primeMockSession(page, { bootPath: "/user" });
return apiMock;
}
test("[BOOKINGS][User][Creation] should create a new booking", async ({ page }) => {
await prepareBookingPage(page);
await completeBookingCreationFlow(page, bookingTestData);
});
test("[BOOKINGS][User][Departments] should hide departments without time booking enabled", async ({
page,
}, testInfo) => {
await prepareBookingPage(page, {
departments: [
{
id: bookingTestData.departmentId,
name: "Bookable Demo",
description: "Bookable address",
exclude_from_invoicing: false,
bookingsystem_time_based_enabled: true,
},
{
id: 77,
name: "Disabled Booking Hall",
description: "Disabled address",
exclude_from_invoicing: false,
bookingsystem_time_based_enabled: false,
},
],
});
await page.goto("/user/bookings/book");
if (isCompactProject(testInfo)) {
const mobileTrigger = page.getByTestId("booking-mobile-department-trigger");
await expect(mobileTrigger).toBeVisible();
await mobileTrigger.click();
await expect(page.getByTestId(`booking-mobile-department-option-${bookingTestData.departmentId}`)).toBeVisible();
await expect(page.getByTestId("booking-mobile-department-option-77")).toHaveCount(0);
} else {
await expect(page.locator(`#department-option-${bookingTestData.departmentId}`)).toBeVisible();
await expect(page.locator("#department-option-77")).toHaveCount(0);
}
await expect(page.getByText("Disabled Booking Hall")).toHaveCount(0);
});
test("[BOOKINGS][User][Creation] should show interior wash products on the interior category", async ({ page }) => {
await prepareBookingPage(page);
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
selectInitialProduct: false,
});
const interiorCategoryTab = page.locator('[data-testid="pos-product-category-tab-2"]:visible').first();
if ((await interiorCategoryTab.count()) > 0) {
await interiorCategoryTab.click();
} else {
await page.locator('[data-testid="pos-product-category-select"]:visible').first().selectOption("2");
}
const interiorProductCard = page.locator('[data-testid="pos-product-card-63"]:visible').first();
await expect(interiorProductCard).toBeVisible();
await expect(interiorProductCard).toContainText("Indvendig vask Forvogn");
});
test("[BOOKINGS][User][Creation] should show desktop loading states while categories and products resolve", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
await prepareBookingPage(page, {
departmentCategoriesDelayMs: 700,
productsDelayMs: 900,
});
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
selectInitialProduct: false,
});
await expect(page.locator('[data-testid="pos-product-categories-loading"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="pos-products-loading"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="pos-product-categories-loading"]:visible')).toHaveCount(0, {
timeout: 10_000,
});
await expect(page.locator('[data-testid="pos-product-card-53"]:visible').first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-testid="pos-products-loading"]:visible')).toHaveCount(0, { timeout: 10_000 });
await page.locator('[data-testid="pos-product-category-tab-2"]:visible').first().click();
await expect(page.locator('[data-testid="pos-products-loading"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="pos-product-card-63"]:visible').first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-testid="pos-products-loading"]:visible')).toHaveCount(0, { timeout: 10_000 });
});
test("[BOOKINGS][User][Rules] should keep the catalog fail-closed while customer rules load", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
await prepareBookingPage(page, { customerAttributesDelayMs: 1_000 });
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
await expect(page.locator('[data-testid="pos-products-loading"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid^="pos-product-card-"]:visible')).toHaveCount(0);
await expect(page.locator('[data-testid^="pos-product-restriction-"]:visible')).toHaveCount(0);
await expect(page.locator('[data-testid="pos-product-card-53"]:visible').first()).toBeVisible({ timeout: 10_000 });
});
test("[BOOKINGS][User][Rules] should retry the full catalog after customer rules fail", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
const { posFixture } = await prepareBookingPage(page, { customerAttributesErrorCount: 100 });
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
const errorPanel = page.locator('[data-testid="pos-customer-restrictions-load-error"]:visible').first();
await expect(errorPanel).toBeVisible();
posFixture.customerAttributesErrorCount = 0;
await errorPanel.getByTestId("pos-customer-restrictions-retry").click();
await expect(errorPanel).toHaveCount(0);
await expect(page.locator('[data-testid="pos-product-card-53"]:visible').first()).toBeVisible({ timeout: 10_000 });
});
test("[BOOKINGS][User][Rules] should block restricted products but allow an exact permitted neighbour", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
const baseFixture = createPosFixture();
const allowedProduct = {
...baseFixture.products.find((product) => Number(product.id) === 53),
id: 54,
name: "Allowed neighbouring wash",
order_priority: 2,
};
await prepareBookingPage(page, {
products: [...baseFixture.products, allowedProduct],
customerAttributesByNumber: {
12345679: [
{
id: 99,
customer_number: 12345679,
attribute: "restrictSpotFree",
product_restriction: {
version: 1,
disabled_product_ids: [53],
collections: [{ id: 501, name: "Blocked booking products", product_ids: [53] }],
},
},
],
},
});
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
const restrictedMutationRequests: Array<Record<string, unknown>> = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (pathname.endsWith("/order/items") || pathname.endsWith("/order-bookings"))) {
restrictedMutationRequests.push(request.postDataJSON() as Record<string, unknown>);
}
});
const allowedCard = page.locator('[data-testid="pos-product-card-54"]:visible').first();
await expect(allowedCard).toHaveAttribute("data-restricted", "false");
await allowedCard.click();
await expect(allowedCard).toHaveClass(/is-selected/);
const restrictedCard = page.locator('[data-testid="pos-product-card-53"]:visible').first();
await expect(restrictedCard).toHaveAttribute("data-restricted", "true");
await expect(restrictedCard.getByTestId("pos-product-restriction-53")).toContainText(/Ikke tilgængelig|Unavailable/i);
await restrictedCard.press("Enter");
await expect(restrictedCard).not.toHaveClass(/is-selected/);
await expect(allowedCard).toHaveClass(/is-selected/);
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
await page.locator(".swal2-confirm:visible").click();
await restrictedCard.click();
await expect(restrictedCard).not.toHaveClass(/is-selected/);
await expect(allowedCard).toHaveClass(/is-selected/);
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
await page.locator(".swal2-confirm:visible").click();
expect(restrictedMutationRequests).toEqual([]);
});
test("[BOOKINGS][User][Rules] should block an interior wash when its required certificate is restricted", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
await prepareBookingPage(page, {
extraProducts: [
{
id: 41,
name: "Vaskecertifikat",
description: "Required wash certificate",
price: 0,
subscription_allowed: false,
category: 8,
piktogram: "certificate",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: false,
order_priority: 5,
addons: [],
},
],
customerAttributesByNumber: {
12345679: [
{
id: 100,
customer_number: 12345679,
attribute: "restrictSpotFree",
product_restriction: {
version: 1,
disabled_product_ids: [41],
collections: [{ id: 502, name: "Blocked certificate", product_ids: [41] }],
},
},
],
},
});
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
await page.locator('[data-testid="pos-product-category-tab-2"]:visible').first().click();
const interiorProductCard = page.locator('[data-testid="pos-product-card-63"]:visible').first();
await interiorProductCard.click();
await expect(page.getByTestId("booking-customer-rule-warning")).toBeVisible();
await expect(interiorProductCard).not.toHaveClass(/is-selected/);
await expect(page.locator("body")).not.toContainText("Vaskecertifikat");
});
test("[BOOKINGS][User][Layout] should keep the selected catalog in a contained grid and only highlight chosen add-ons", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
const baseFixture = createPosFixture();
const addonProduct = baseFixture.products.find((product) => Number(product.id) === 65);
const primaryProduct = {
...baseFixture.products.find((product) => Number(product.id) === 53),
addons: [
{
id: 9005,
option_id: addonProduct.id,
name: addonProduct.name,
price: addonProduct.price,
quantity: 0,
min: 0,
max: 3,
product: { ...addonProduct, quantity: 0 },
},
],
};
await prepareBookingPage(page, {
products: [primaryProduct, ...baseFixture.products.filter((product) => Number(product.id) !== 53)],
});
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
const productCard = page.locator('[data-testid="pos-product-card-53"]:visible').first();
await productCard.click();
const catalog = productCard.locator("xpath=ancestor::div[contains(@class, 'tw-scroll-minimal')][1]");
await expect
.poll(() => catalog.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1);
const addonButton = page.locator('[data-testid="pos-mobile-addon-65"]:visible').first();
const addonRow = addonButton.locator("xpath=ancestor::div[@data-selected][1]");
await expect(addonRow).toHaveAttribute("data-selected", "false");
await page.locator('[data-testid="pos-mobile-addon-65-increase"]:visible').first().click();
await expect(addonRow).toHaveAttribute("data-selected", "true");
});
test("[BOOKINGS][User][Layout] should render the mobile booking flow at tablet widths", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop browser only");
await page.setViewportSize({ width: 900, height: 900 });
await prepareBookingPage(page);
await page.goto("/user/bookings/book");
await expect(page.getByTestId("booking-mobile-department-trigger")).toBeVisible();
await expect(page.locator(".mobile-bottom-nav")).toBeVisible();
});
test("[BOOKINGS][User][Rules] should recover from a stale server-side restriction without losing booking details", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
await prepareBookingPage(page, { orderBookingRestrictedProductIds: [40] });
const { reference, poNumber, notes } = await goToBookingProductSelectionStepWithOptions(page, bookingTestData);
await selectBookingDateTime(page);
await fillBookingField(page, "booking-reference-toggle", "booking-reference-input", reference);
await fillBookingField(page, "booking-po-toggle", "booking-po-input", poNumber);
await fillBookingField(page, "booking-notes-toggle", "booking-notes-input", notes);
await page.check("#pickup");
await page.click("#go-to-confirmation-button");
const rejectedResponse = page.waitForResponse(
(response) => response.request().method() === "POST" && response.url().includes("/order-bookings"),
{ timeout: 10_000 }
);
await page.locator('.booking-summary-overlay:visible [data-testid="booking-summary-confirm-body"]').click();
expect((await rejectedResponse).status()).toBe(400);
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
await page.locator(".swal2-confirm:visible").click();
await expect(page.getByTestId("booking-customer-rule-warning")).toContainText(/afvist|rejected/i);
await expect(page.locator('[data-testid="pos-product-card-53"]:visible').first()).toHaveClass(/is-selected/);
await expect(page.locator("#pickup")).not.toBeChecked();
await expect(page.locator("body")).toContainText(reference);
await expect(page.locator("body")).toContainText(poNumber);
});
test("[BOOKINGS][User][Creation] should submit resolved wash certificate price for visible-price customers", async ({
page,
}) => {
await prepareBookingPage(
page,
{
extraProducts: [
{
id: 41,
name: "Vaskecertifikat",
description: "Wash certificate",
price: 0,
subscription_allowed: false,
category: 8,
piktogram: "certificate",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: false,
order_priority: 5,
addons: [],
},
],
productFinalPricesById: {
41: 175,
},
},
{
permissions: ["user", "has_attribute_showPricesOnBookingPage"],
}
);
const { departmentId, registrationNumber, reference, poNumber, notes, mobileWizard } =
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
selectInitialProduct: false,
});
const interiorCategoryTab = page.locator('[data-testid="pos-product-category-tab-2"]:visible').first();
if ((await interiorCategoryTab.count()) > 0) {
await interiorCategoryTab.click();
} else {
await page.locator('[data-testid="pos-product-category-select"]:visible').first().selectOption("2");
}
const interiorProductCard = page.locator('[data-testid="pos-product-card-63"]:visible').first();
await expect(interiorProductCard).toBeVisible();
await interiorProductCard.click();
await expect(page.locator("body")).toContainText("Vaskecertifikat");
await expect(page.locator("body")).toContainText(/175,00\s*kr\./);
if (mobileWizard) {
await expect(page.getByTestId("booking-mobile-next")).toBeEnabled();
await page.getByTestId("booking-mobile-next").click();
}
const selectedDateTime = await selectBookingDateTime(page);
await fillBookingField(page, "booking-reference-toggle", "booking-reference-input", reference);
await fillBookingField(page, "booking-po-toggle", "booking-po-input", poNumber);
await fillBookingField(page, "booking-notes-toggle", "booking-notes-input", notes);
if (mobileWizard) {
await page.check("#pickup-mobile");
await expect(page.getByTestId("booking-mobile-next")).toBeEnabled();
await page.getByTestId("booking-mobile-next").click();
} else {
await page.check("#pickup");
await page.click("#go-to-confirmation-button");
}
await expect(page.locator("#booking-summary-title")).toBeVisible();
await expect(page.getByTestId("booking-summary-date-time")).toContainText(selectedDateTime.summaryDateLabel);
const summaryOverlay = page.locator(".booking-summary-overlay:visible").first();
await expect(summaryOverlay).toContainText("Vaskecertifikat");
await expect(summaryOverlay).toContainText(/175,00\s*kr\./);
const [request] = await Promise.all([
page.waitForRequest((request) => request.method() === "POST" && request.url().includes("/order-bookings"), {
timeout: 10_000,
}),
summaryOverlay.getByTestId("booking-summary-confirm-body").click({ force: true }),
]);
const payload = request.postDataJSON();
const certificateItem = payload.items.find((item: { id?: number }) => Number(item.id) === 41);
expect(certificateItem).toBeTruthy();
expect(Number(certificateItem.price)).toBe(175);
expect(Number(certificateItem.quantity)).toBe(1);
expect(Number(payload.department)).toBe(departmentId);
expect(payload.reg_1).toBe(registrationNumber);
});