Resolve direct-order product reconciliation context, preserve valid selections when restricted products are activated, remove the redundant certificate request, and add focused regression coverage.
435 lines
18 KiB
TypeScript
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);
|
|
});
|