Closes the open POS step-2 bug where only some addons are persisted to the order. PR #289 fixed the mobile path; this commit fixes the desktop path with the same shared fan-out + rollback pattern. ## Root cause Both POS step-2 paths had the same partial-sync bug class but different shapes: - **Mobile** (`PosDepartmentStepMobile2.vue → syncMobileOrderItems.js`): used `Promise.all` over parallel POSTs that short-circuits on first rejection. - **Desktop** (`SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`): used a sequential `await` loop with `.catch(handleCreateOrderItemError)` that breaks on first failure. Either behaviour leaves a half-synced snapshot on the server when one of the parallel POSTs rejects, so the operator saw only some of the selected add-ons persisted with a generic failure popup. ## Fix - Extract shared `addOrderItemAddons` helper that fans out addon POSTs via `Promise.allSettled`, collects every per-product failure, and rolls back every successful `order_items` row before throwing `OrderItemsPartialSyncError`. - Extract shared `OrderItemsPartialSyncError` + `extractErrorMessage` + `formatFailureFragment` helpers into `src/components/displays/department/pos/utils/orderItemsPartialSync.js`. - Wire desktop `SelectProductsFormPOS.vue → addAddonsToOrderMiddleware` to the shared helper. - Wire mobile `syncMobileOrderItems.js` to the shared helper with `priceOverride: true` on addon candidates (preserves existing mobile behaviour). ## Tests - New `tests/unit/order-items-addon-fanout.spec.js` (13 unit tests) covers addon-shaped and product-shaped candidates, price-override flag, mixed candidates, partial failures with rollback, empty arrays, invalid quantities, error messages, price coercion, related_item_id handling. - New e2e test in `tests/e2e/pos-customer-rules.spec.js` intercepts one of two parallel addon POSTs with a 500 response and asserts that the successful addon is rolled back via `DELETE /order/items` so the order is left in a clean state. ## Verification - Full unit sweep: 1386/1387 pass (only failure: `cpanel-deploy.spec.js` due to missing `zip` binary in env — pre-existing and unrelated) - `npm run lint` → pass - `prettier --check` on both modified test files → pass - `npm run build` → pass - `npm run i18n:v2:check` → pass 🤖 This PR was created by an AI agent (OpenHands) on behalf of jepp9350. Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev>
804 lines
28 KiB
JavaScript
804 lines
28 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
|
|
|
|
const POS_PERMISSIONS = [
|
|
"admin",
|
|
"department_access_12",
|
|
"delete_order",
|
|
"edit_order_items",
|
|
"get_user",
|
|
"list_customer_attributes",
|
|
"get_custom_prices_other",
|
|
];
|
|
const POS_STEP_TIMEOUT = 20_000;
|
|
|
|
function json(body, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
async function primeOperatorSession(page, token = "pos-customer-rules-token") {
|
|
await primeMockSession(page, { token, bootPath: "/login" });
|
|
}
|
|
|
|
async function openPosAndSelectCustomer(page, customer) {
|
|
await page.goto("/admin/12/modules/pos?step=1");
|
|
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
await page.locator("#reg_1").fill("AB12345");
|
|
await page.locator("#pos_select_customer_input").fill(String(customer.customerNumber));
|
|
await expect(page.locator(".customer-drop-down-select").first()).toBeVisible();
|
|
await page.locator(".customer-drop-down-select").first().click();
|
|
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(customer.name);
|
|
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: customer.name }).first()).toBeVisible();
|
|
}
|
|
|
|
async function expectSameRenderedWidth(left, right, tolerance = 1) {
|
|
await expect(left).toBeVisible();
|
|
await expect(right).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [leftBox, rightBox] = await Promise.all([left.boundingBox(), right.boundingBox()]);
|
|
if (!leftBox || !rightBox) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
return Math.abs(leftBox.width - rightBox.width);
|
|
})
|
|
.toBeLessThanOrEqual(tolerance);
|
|
}
|
|
|
|
async function expectControlsContained(container, controls, tolerance = 1) {
|
|
await expect(container).toBeVisible();
|
|
await Promise.all(controls.map((control) => expect(control).toBeVisible()));
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [containerBox, ...controlBoxes] = await Promise.all([
|
|
container.boundingBox(),
|
|
...controls.map((control) => control.boundingBox()),
|
|
]);
|
|
if (!containerBox || controlBoxes.some((box) => !box)) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
const containerRight = containerBox.x + containerBox.width;
|
|
return Math.max(
|
|
...controlBoxes.flatMap((box) => [containerBox.x - box.x, box.x + box.width - containerRight]),
|
|
0
|
|
);
|
|
})
|
|
.toBeLessThanOrEqual(tolerance);
|
|
}
|
|
|
|
function buildTodayTimestamp(time = "10:00:00.000Z") {
|
|
return `${new Date().toISOString().split("T")[0]}T${time}`;
|
|
}
|
|
|
|
async function commitDesktopReg1ByBlur(page, value = "AB12345") {
|
|
const reg1Input = page.locator("#reg_1");
|
|
await reg1Input.fill(value);
|
|
await reg1Input.evaluate((element) => {
|
|
element.blur();
|
|
});
|
|
}
|
|
|
|
function getSelectedCustomerPhoneValue(page) {
|
|
return page
|
|
.locator(".pos-selected-customer__row")
|
|
.filter({ has: page.locator(".pos-selected-customer__label", { hasText: "Telefon" }) })
|
|
.first()
|
|
.locator(".pos-selected-customer__value");
|
|
}
|
|
|
|
test("rules tab reloads customer attributes when the panel becomes visible", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const fixture = createPosFixture({
|
|
customerAttributesByNumber: {
|
|
12345679: [
|
|
{ id: 1, customer_number: 12345679, attribute: "invoiceAllOrdersIndividually" },
|
|
{ id: 2, customer_number: 12345679, attribute: "invoiceWithStripe" },
|
|
],
|
|
},
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
await primeOperatorSession(page);
|
|
|
|
await page.goto("/admin/12/modules/pos?step=1");
|
|
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
await page.locator("#reg_1").fill("AB12345");
|
|
await page.locator("#pos_select_customer_input").fill(String(customer.customerNumber));
|
|
await expect(page.locator(".customer-drop-down-select").first()).toBeVisible();
|
|
await page.locator(".customer-drop-down-select").first().click();
|
|
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(customer.name);
|
|
|
|
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: customer.name }).first()).toBeVisible();
|
|
|
|
const rulesTab = page.locator('[data-testid="pos-customer-tab-rules"]:visible').first();
|
|
await expect(rulesTab).toBeVisible();
|
|
|
|
const customerAttributesResponse = page.waitForResponse((response) => {
|
|
return (
|
|
response.request().method() === "GET" &&
|
|
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
|
|
);
|
|
});
|
|
|
|
await rulesTab.click();
|
|
await customerAttributesResponse;
|
|
|
|
await expect(page.locator("#invoiceAllOrdersIndividually:visible")).toBeChecked();
|
|
await expect(page.locator("#invoiceWithStripe:visible")).toBeChecked();
|
|
});
|
|
|
|
test("duplicate warning moves below customer rules when rules tab is selected", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const fixture = createPosFixture({
|
|
ordersById: {
|
|
54520: {
|
|
id: 54520,
|
|
customer_id: 12345679,
|
|
department_id: 12,
|
|
reference: "DUP-RULES-TAB",
|
|
po: "",
|
|
safety_seal: "",
|
|
notes: "",
|
|
reg_1: "AB12345",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: null,
|
|
booking_id: null,
|
|
completed_at: null,
|
|
closed_at: null,
|
|
created_at: buildTodayTimestamp("09:30:00.000Z"),
|
|
include_in_invoice: null,
|
|
},
|
|
},
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
await primeOperatorSession(page, "pos-customer-rules-duplicate-placement-token");
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await commitDesktopReg1ByBlur(page);
|
|
|
|
const actionRail = page.getByTestId("pos-step-1-action-rail");
|
|
const actionRailWarning = actionRail.getByTestId("pos-desktop-duplicate-warning-inline");
|
|
await expect(actionRailWarning).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.locator('[data-testid="pos-customer-tab-rules"]:visible').first().click();
|
|
|
|
const rulesFooter = page.getByTestId("pos-customer-rules-footer");
|
|
const rulesFooterWarning = rulesFooter.getByTestId("pos-desktop-duplicate-warning-inline");
|
|
const rulesFooterNext = rulesFooter.getByTestId("pos-next-step");
|
|
await expect(rulesFooterWarning).toBeVisible({ timeout: 10_000 });
|
|
await expect(rulesFooterNext).toBeVisible({ timeout: 10_000 });
|
|
await expect(actionRail).toHaveCount(0);
|
|
|
|
const lastRuleRow = page.getByTestId("pos-customer-rule-row").last();
|
|
const lastRuleBox = await lastRuleRow.boundingBox();
|
|
const warningBox = await rulesFooterWarning.boundingBox();
|
|
const footerNextBox = await rulesFooterNext.boundingBox();
|
|
expect(lastRuleBox).not.toBeNull();
|
|
expect(warningBox).not.toBeNull();
|
|
expect(footerNextBox).not.toBeNull();
|
|
expect(warningBox.y).toBeGreaterThanOrEqual(lastRuleBox.y + lastRuleBox.height);
|
|
expect(footerNextBox.y).toBeGreaterThanOrEqual(warningBox.y + warningBox.height);
|
|
|
|
await page.locator('[data-testid="pos-customer-tab-details"]:visible').first().click();
|
|
|
|
await expect(actionRailWarning).toBeVisible({ timeout: 10_000 });
|
|
await expect(actionRail.getByTestId("pos-next-step")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByTestId("pos-customer-rules-footer")).toHaveCount(0);
|
|
});
|
|
|
|
test("customer details renders top-level phone object when economic customer payload is invalid", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const fixture = createPosFixture();
|
|
const customer = fixture.customersByNumber[12345679];
|
|
let usersCustomerInterceptCount = 0;
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
|
|
await page.route(/\/users\/customer(?:\?.*)?$/, async (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
usersCustomerInterceptCount += 1;
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
customer_name: customer.name,
|
|
phone: {
|
|
country_code: 45,
|
|
number: 42331128,
|
|
},
|
|
economic_customer: [],
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
await primeOperatorSession(page);
|
|
await page.goto(`/admin/12/modules/pos?customer_id=${customer.customerNumber}&step=1`);
|
|
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: customer.name }).first()).toBeVisible();
|
|
await expect.poll(() => usersCustomerInterceptCount).toBeGreaterThan(0);
|
|
|
|
const phoneValue = getSelectedCustomerPhoneValue(page);
|
|
await expect(phoneValue).toHaveText("42331128");
|
|
await expect(phoneValue).not.toContainText("{");
|
|
await expect(phoneValue).not.toContainText("country_code");
|
|
await expect(phoneValue).not.toContainText("number");
|
|
});
|
|
|
|
test("customer details renders economic customer mobilePhone object as number only", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const baseFixture = createPosFixture();
|
|
const baseCustomer = baseFixture.customersByNumber[12345679];
|
|
const fixture = createPosFixture({
|
|
customersByNumber: {
|
|
12345679: {
|
|
...baseCustomer,
|
|
mobilePhone: {
|
|
country_code: 45,
|
|
number: 42331128,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
|
|
await primeOperatorSession(page);
|
|
await openPosAndSelectCustomer(page, customer);
|
|
|
|
const phoneValue = getSelectedCustomerPhoneValue(page);
|
|
await expect(phoneValue).toHaveText("42331128");
|
|
await expect(phoneValue).not.toContainText("{");
|
|
await expect(phoneValue).not.toContainText("country_code");
|
|
await expect(phoneValue).not.toContainText("number");
|
|
});
|
|
|
|
test("only tankcleaning customers can only add tankcleaning products", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const baseFixture = createPosFixture();
|
|
const tankCleaningProduct = {
|
|
id: 66,
|
|
name: "Tank cleaning 4 spulehoveder",
|
|
description: "Tankcleaning service",
|
|
price: 799,
|
|
subscription_allowed: true,
|
|
category: 5,
|
|
piktogram: "truck",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 1,
|
|
addons: [],
|
|
};
|
|
const fixture = createPosFixture({
|
|
customerAttributesByNumber: {
|
|
12345679: [
|
|
{
|
|
id: 1,
|
|
customer_number: 12345679,
|
|
attribute: "onlyTankCleaning",
|
|
product_restriction: {
|
|
version: 1,
|
|
disabled_product_ids: [53],
|
|
collections: [{ id: 501, name: "Legacy migration", product_ids: [53] }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
products: [...baseFixture.products, tankCleaningProduct],
|
|
departmentCategories: [
|
|
...baseFixture.departmentCategories,
|
|
{ id: 13, department_id: 12, category: { id: 5, name: "Tankcleaning", meta: { products: [66] } } },
|
|
],
|
|
});
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
await primeOperatorSession(page, "pos-only-tankcleaning-token");
|
|
|
|
const orderItemPosts = [];
|
|
page.on("request", (request) => {
|
|
if (request.method() === "POST" && new URL(request.url()).pathname.endsWith("/order/items")) {
|
|
orderItemPosts.push(request.postDataJSON());
|
|
}
|
|
});
|
|
|
|
const customer = fixture.customersByNumber[12345679];
|
|
const customerAttributesResponse = page.waitForResponse((response) => {
|
|
return (
|
|
response.request().method() === "GET" &&
|
|
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
|
|
);
|
|
});
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await customerAttributesResponse;
|
|
|
|
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
const restrictedProductCard = page.getByTestId("pos-product-card-53").first();
|
|
await restrictedProductCard.click();
|
|
await expect(restrictedProductCard).not.toHaveClass(/is-selected/);
|
|
await expect(page.locator(".swal2-popup:visible")).toContainText("Produktet er ikke tilladt for denne kunde");
|
|
await page.locator(".swal2-confirm:visible").click();
|
|
await expect(page.getByTestId("pos-add-to-cart-53")).toHaveCount(0);
|
|
expect(orderItemPosts).toEqual([]);
|
|
|
|
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
|
|
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
|
|
await page.getByTestId("pos-product-card-66").first().click();
|
|
await expect(page.getByTestId("pos-add-to-cart-66").first()).toBeEnabled();
|
|
|
|
const addRequest = page.waitForRequest((request) => {
|
|
return request.method() === "POST" && request.url().includes("/order/items");
|
|
});
|
|
await page.getByTestId("pos-add-to-cart-66").first().click();
|
|
const request = await addRequest;
|
|
expect(request.postDataJSON().product_id).toBe(66);
|
|
expect(orderItemPosts).toHaveLength(1);
|
|
});
|
|
|
|
test("exact customer-rule configuration disables desktop add-on controls by option_id without placeholder rows", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const baseFixture = createPosFixture();
|
|
const primaryProduct = {
|
|
...baseFixture.products.find((product) => Number(product.id) === 53),
|
|
};
|
|
const addonProduct = {
|
|
...baseFixture.products.find((product) => Number(product.id) === 63),
|
|
addons: [],
|
|
};
|
|
const allowedLookalikeProduct = {
|
|
...addonProduct,
|
|
id: 64,
|
|
name: "Allowed additional-service lookalike",
|
|
};
|
|
primaryProduct.addons = [
|
|
{
|
|
id: 9001,
|
|
option_id: addonProduct.id,
|
|
name: addonProduct.name,
|
|
price: addonProduct.price,
|
|
product: addonProduct,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
{
|
|
id: 9002,
|
|
option_id: allowedLookalikeProduct.id,
|
|
name: allowedLookalikeProduct.name,
|
|
price: allowedLookalikeProduct.price,
|
|
product: allowedLookalikeProduct,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
];
|
|
const fixture = createPosFixture({
|
|
customerAttributesByNumber: {
|
|
12345679: [
|
|
{
|
|
id: 1,
|
|
customer_number: 12345679,
|
|
attribute: "restrictAdditionalServices",
|
|
product_restriction: {
|
|
version: 1,
|
|
disabled_product_ids: [addonProduct.id],
|
|
collections: [{ id: 502, name: "Blocked add-ons", product_ids: [addonProduct.id] }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
products: [
|
|
primaryProduct,
|
|
addonProduct,
|
|
allowedLookalikeProduct,
|
|
...baseFixture.products.filter((product) => ![53, 63, 64].includes(Number(product.id))),
|
|
],
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
const orderItemPosts = [];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
page.on("request", (request) => {
|
|
if (request.method() === "POST" && request.url().includes("/order/items")) {
|
|
orderItemPosts.push(request.postDataJSON());
|
|
}
|
|
});
|
|
await primeOperatorSession(page, "pos-restricted-desktop-addons-token");
|
|
|
|
const customerAttributesResponse = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "GET" &&
|
|
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
|
|
);
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await customerAttributesResponse;
|
|
|
|
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
await page.getByTestId("pos-product-card-53").first().click();
|
|
const restrictedAddonRow = page.getByTestId("pos-addon-restriction-63");
|
|
const restrictedIncreaseButton = page.getByTestId("pos-addon-63-increase");
|
|
const restrictedAddonButton = page.getByTestId("pos-addon-63-name");
|
|
const restrictedDecreaseButton = page.getByTestId("pos-addon-63-decrease");
|
|
const allowedAddonButton = page.getByTestId("pos-addon-64-name");
|
|
await expect(restrictedAddonRow).toBeVisible();
|
|
await expect(restrictedIncreaseButton).toBeDisabled();
|
|
await expect(restrictedAddonButton).toBeDisabled();
|
|
await expect(restrictedDecreaseButton).toBeDisabled();
|
|
await expect(allowedAddonButton).toBeEnabled();
|
|
await expectSameRenderedWidth(restrictedAddonButton, allowedAddonButton);
|
|
await expectControlsContained(restrictedAddonRow, [
|
|
restrictedIncreaseButton,
|
|
restrictedAddonButton,
|
|
restrictedDecreaseButton,
|
|
]);
|
|
|
|
await page.getByTestId("pos-addon-restriction-tooltip-63").hover();
|
|
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
|
|
"Tilvalg er ikke tilladt for denne kunde"
|
|
);
|
|
|
|
for (const testId of ["pos-addon-63-increase", "pos-addon-63-name", "pos-addon-63-decrease"]) {
|
|
await page.getByTestId(testId).evaluate((button) => button.click());
|
|
}
|
|
expect(orderItemPosts).toEqual([]);
|
|
|
|
await allowedAddonButton.click();
|
|
|
|
await page.getByTestId("pos-add-to-cart-53").first().click();
|
|
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(2);
|
|
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53, 64]);
|
|
expect(Number(orderItemPosts[1].related_item_id)).toBeGreaterThan(0);
|
|
const cartPanel = page.getByTestId("pos-step-2").getByTestId("pos-order-panel-cart");
|
|
await expect(cartPanel).not.toContainText("Ingen data");
|
|
await expect(cartPanel).not.toContainText("0 DKK");
|
|
});
|
|
|
|
test("desktop remains fail-closed when an active product rule has a malformed restriction payload", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const fixture = createPosFixture({
|
|
customerAttributesByNumber: {
|
|
12345679: [{ id: 1, customer_number: 12345679, attribute: "restrictSpotFree" }],
|
|
},
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
const orderItemPosts = [];
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
page.on("request", (request) => {
|
|
if (request.method() === "POST" && request.url().includes("/order/items")) {
|
|
orderItemPosts.push(request.postDataJSON());
|
|
}
|
|
});
|
|
await primeOperatorSession(page, "pos-malformed-customer-restrictions-token");
|
|
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
await expect(page.getByTestId("pos-customer-restrictions-load-error")).toBeVisible();
|
|
|
|
const blockedProductCard = page.getByTestId("pos-product-card-53").first();
|
|
await blockedProductCard.click();
|
|
await expect(blockedProductCard).not.toHaveClass(/is-selected/);
|
|
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
|
|
await page.locator(".swal2-confirm:visible").click();
|
|
await expect(page.locator('[data-testid^="pos-add-to-cart-"]:visible')).toHaveCount(0);
|
|
expect(orderItemPosts).toEqual([]);
|
|
});
|
|
|
|
test("desktop clears pending basket row when customer-rule create is rejected", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
const fixture = createPosFixture();
|
|
const customer = fixture.customersByNumber[12345679];
|
|
let rejectedCreateCount = 0;
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
await page.route(/\/order\/items(?:\?.*)?$/, async (route) => {
|
|
const request = route.request();
|
|
if (request.method() !== "POST") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
rejectedCreateCount += 1;
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: {
|
|
code: "CUSTOMER_RULE_PRODUCT_RESTRICTED",
|
|
message: "This product is not allowed for the selected customer",
|
|
},
|
|
},
|
|
400
|
|
)
|
|
);
|
|
});
|
|
await primeOperatorSession(page, "pos-customer-rule-reject-token");
|
|
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
await page.getByTestId("pos-product-card-53").first().click();
|
|
await page.getByTestId("pos-add-to-cart-53").first().click();
|
|
|
|
await expect.poll(() => rejectedCreateCount, { timeout: 10_000 }).toBe(1);
|
|
await expect(page.getByText("Produktet blev afvist af kundereglerne og er ikke tilføjet")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
const cartPanel = page.getByTestId("pos-step-2").getByTestId("pos-order-panel-cart");
|
|
await expect(cartPanel.getByTestId("pos-order-empty-state")).toBeVisible();
|
|
await expect(cartPanel.locator('[data-testid^="pos-order-item-name-"]')).toHaveCount(0);
|
|
});
|
|
|
|
test("desktop rolls back partial step-2 addon sync when one addon POST rejects", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
|
|
|
// Regression test for the bug where, on the desktop POS step 2, only some
|
|
// addons ended up on the order when one of the parallel add-on POSTs was
|
|
// rejected. The shared `addOrderItemAddons` helper now fans out the add-on
|
|
// POSTs via Promise.allSettled and rolls back every order_items row that
|
|
// landed during the failed attempt, so the operator sees a clean retry
|
|
// state with no half-synced addons left on the order.
|
|
const baseFixture = createPosFixture();
|
|
const orderId = 9710;
|
|
const primaryProduct = {
|
|
...baseFixture.products.find((product) => Number(product.id) === 53),
|
|
};
|
|
const addonA = {
|
|
...baseFixture.products.find((product) => Number(product.id) === 63),
|
|
addons: [],
|
|
};
|
|
const addonB = {
|
|
...baseFixture.products.find((product) => Number(product.id) === 64),
|
|
addons: [],
|
|
};
|
|
addonA.id = 7001;
|
|
addonA.name = "Addon A (will be rejected)";
|
|
addonB.id = 7002;
|
|
addonB.name = "Addon B (will be rolled back)";
|
|
primaryProduct.addons = [
|
|
{
|
|
id: 8001,
|
|
option_id: 7001,
|
|
name: addonA.name,
|
|
price: addonA.price,
|
|
product: addonA,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
{
|
|
id: 8002,
|
|
option_id: 7002,
|
|
name: addonB.name,
|
|
price: addonB.price,
|
|
product: addonB,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
];
|
|
const fixture = createPosFixture({
|
|
ordersById: {
|
|
9710: {
|
|
id: 9710,
|
|
customer_id: 12345679,
|
|
department_id: 12,
|
|
reference: "STEP2-DESKTOP-ROLLBACK",
|
|
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",
|
|
},
|
|
...baseFixture.ordersById,
|
|
},
|
|
orderItemsByOrderId: {
|
|
[orderId]: [],
|
|
},
|
|
products: [
|
|
primaryProduct,
|
|
addonA,
|
|
addonB,
|
|
...baseFixture.products.filter((product) => ![53, 63, 64].includes(Number(product.id))),
|
|
],
|
|
});
|
|
const customer = fixture.customersByNumber[12345679];
|
|
const orderItemPosts = [];
|
|
const orderItemResponseIds = [];
|
|
const orderItemDeletes = [];
|
|
let rejectedAddonsRemaining = 0;
|
|
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: POS_PERMISSIONS,
|
|
edgeGateways: false,
|
|
pos: fixture,
|
|
});
|
|
await page.route(/\/order\/items(?:\?.*)?$/, async (route) => {
|
|
const request = route.request();
|
|
if (request.method() === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
orderItemPosts.push(body);
|
|
// Reject the first add-on POST that targets the configured add-on
|
|
// product. The shared helper fans out the add-ons in parallel via
|
|
// Promise.allSettled, so the other add-on may have already landed by
|
|
// the time the rejection is returned; the helper must roll it back.
|
|
if (Number(body.product_id) === 7001 && rejectedAddonsRemaining > 0) {
|
|
rejectedAddonsRemaining -= 1;
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: { message: "Addon 7001 rejected by backend" },
|
|
},
|
|
400
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
if (request.method() === "DELETE") {
|
|
const url = new URL(request.url());
|
|
const id = Number(url.searchParams.get("id") || 0);
|
|
orderItemDeletes.push(id);
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
});
|
|
// Capture successful POST responses so the test can map the order_items
|
|
// id back to the request that produced it. The route handler above only
|
|
// short-circuits the 7001 rejection; every other POST falls through to
|
|
// mockApi and surfaces here with its server-generated id.
|
|
page.on("response", async (response) => {
|
|
if (response.request().method() !== "POST") {
|
|
return;
|
|
}
|
|
if (!/\/order\/items(?:\?.*)?$/.test(response.url())) {
|
|
return;
|
|
}
|
|
if (response.status() >= 400) {
|
|
return;
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = await response.json();
|
|
} catch (error) {
|
|
payload = null;
|
|
}
|
|
const id = Number(payload?.data?.id ?? payload?.id ?? 0);
|
|
if (id > 0) {
|
|
orderItemResponseIds.push(id);
|
|
}
|
|
});
|
|
await primeOperatorSession(page, "pos-desktop-step2-rollback-token");
|
|
|
|
await openPosAndSelectCustomer(page, customer);
|
|
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
|
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
|
|
|
await page.getByTestId("pos-product-card-53").first().click();
|
|
await expect(page.getByTestId("pos-addon-7001-name")).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByTestId("pos-addon-7002-name")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Select both add-ons so the desktop middleware fans out two POSTs.
|
|
await page.getByTestId("pos-addon-7001-name").click();
|
|
await page.getByTestId("pos-addon-7002-name").click();
|
|
|
|
rejectedAddonsRemaining = 1;
|
|
|
|
// Click add-to-cart. The primary POST must succeed; the first addon POST
|
|
// will reject; the second addon POST will succeed and must be rolled back.
|
|
await page.getByTestId("pos-add-to-cart-53").first().click();
|
|
|
|
// 1 primary + 2 addons = 3 POST attempts.
|
|
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(3);
|
|
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53, 7001, 7002]);
|
|
|
|
// The primary must be linked to no parent (related_item_id is null or 0).
|
|
expect(Number(orderItemPosts[0].related_item_id || 0)).toBe(0);
|
|
// Both addons must be linked to the primary by related_item_id.
|
|
expect(Number(orderItemPosts[1].related_item_id)).toBeGreaterThan(0);
|
|
expect(Number(orderItemPosts[2].related_item_id)).toBeGreaterThan(0);
|
|
|
|
// The successful addon (7002) must be rolled back via DELETE /order/items.
|
|
// The shared helper uses Promise.allSettled under the hood, so by the
|
|
// time the 7001 rejection surfaces the 7002 POST may have already landed;
|
|
// the rollback must clean it up so the operator can retry without the
|
|
// previous attempt's half-saved addon lingering on the order.
|
|
await expect.poll(() => orderItemDeletes.length, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
|
|
|
// The 7001 rejection never reached the server, so no DELETE may target
|
|
// an order_items row that points at product 7001. The middleware only
|
|
// deletes the rows it created during the failed attempt — the addon 7002
|
|
// row that came back with a successful POST. The primary 53 row is owned
|
|
// by the surrounding createOrderItem call and must remain untouched, so
|
|
// the rollback list must never contain the primary's order_item id.
|
|
await expect.poll(() => orderItemResponseIds[0], { timeout: 10_000 }).toBeGreaterThan(0);
|
|
const primaryOrderItemId = orderItemResponseIds[0];
|
|
expect(orderItemDeletes).not.toContain(primaryOrderItemId);
|
|
// The 7002 row (which landed) must be among the rolled-back ids.
|
|
expect(orderItemResponseIds).toContain(orderItemDeletes[0]);
|
|
});
|