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.
This commit is contained in:
Jeppe B
2026-07-21 15:42:50 +02:00
committed by GitHub
parent 71e7fac555
commit a0c11e4bb7
9 changed files with 400 additions and 41 deletions
+6
View File
@@ -135,6 +135,12 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"], specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{
name: "shared-pos-product-selection",
patterns: [/^src\/components\/forms\/department\/pos\/SelectProductsFormPOS\.vue$/u],
specs: ["tests/e2e/pos-customer-rules.spec.js", "tests/e2e/userBookWash.spec.ts"],
projects: chromiumProjects,
},
{ {
name: "pos", name: "pos",
patterns: [ patterns: [
@@ -186,11 +186,7 @@ const fetchProductsForCategory = async ({
try { try {
if (customerNumber) { if (customerNumber) {
await loadCustomerAttributes(customerNumber); await loadCustomerAttributes(customerNumber);
if ( if (requestId !== latestProductsRequestId.value) {
requestId !== latestProductsRequestId.value ||
customer_attributes_status.value !== 'ready' ||
Number(customer_attributes_customer_number.value) !== Number(customerNumber)
) {
return; return;
} }
} }
@@ -1057,7 +1053,9 @@ watch([isGridLayout, effectiveSelectedId], (newVals) => {
const selectProduct = (product) => { const selectProduct = (product) => {
const restriction = getScopedProductRestriction(product); const restriction = getScopedProductRestriction(product);
if (!customerRulesReady.value || restriction.restricted) { if (!customerRulesReady.value || restriction.restricted) {
void showRestrictionWarning(restriction.messageKey); void showRestrictionWarning(
restriction.messageKey || (!customerRulesReady.value ? "pos.restrictions.loading" : undefined)
);
return; return;
} }
emits('onSelectProduct', product); emits('onSelectProduct', product);
@@ -1554,8 +1552,7 @@ const onSelectCategory = (rawId) => {
:data-testid="`pos-product-card-${product.id}`" :data-testid="`pos-product-card-${product.id}`"
:data-restricted="isProductUnavailable(product) ? 'true' : 'false'" :data-restricted="isProductUnavailable(product) ? 'true' : 'false'"
role="button" role="button"
:tabindex="isProductUnavailable(product) ? -1 : 0" tabindex="0"
:aria-disabled="isProductUnavailable(product) ? 'true' : 'false'"
@keydown.enter.prevent="selectProduct(product)" @keydown.enter.prevent="selectProduct(product)"
@keydown.space.prevent="selectProduct(product)" @keydown.space.prevent="selectProduct(product)"
:ref="el => setItemRef(el, product.id)" :ref="el => setItemRef(el, product.id)"
+69 -1
View File
@@ -1459,6 +1459,74 @@ export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
}; };
/** Get order items */ /** Get order items */
export const reconcileOrderItemProducts = async (items = [], { departmentId, customerId } = {}) => {
const normalizedItems = Array.isArray(items) ? items : [];
const normalizedDepartmentId =
departmentId === undefined ? toPositiveInteger(department_id.value) : toPositiveInteger(departmentId);
const normalizedCustomerId =
customerId === undefined ? getSelectedCustomerNumber() : resolveCustomerNumber(customerId);
const productsById = new Map();
const productRequestsById = new Map();
for (const item of normalizedItems) {
const productId = toPositiveInteger(item?.product_id);
const embeddedProductId = toPositiveInteger(item?.product?.id);
if (productId && productId === embeddedProductId) {
productsById.set(embeddedProductId, item.product);
}
}
const getCanonicalProduct = async (productId, orderItemId) => {
const embeddedProduct = productsById.get(productId) ?? null;
if (embeddedProduct) {
return embeddedProduct;
}
let productRequest = productRequestsById.get(productId);
if (!productRequest) {
productRequest = SessionUser.objects.products.get.single(productId, {
department_id: normalizedDepartmentId,
customer_id: normalizedCustomerId,
category_id: null,
final_price: true,
}).then((canonicalProduct) => {
if (canonicalProduct) {
productsById.set(productId, canonicalProduct);
}
return canonicalProduct;
}).catch((error) => {
console.warn(`Unable to reconcile product ${productId} for order item ${orderItemId ?? "unknown"}`, error);
return null;
});
productRequestsById.set(productId, productRequest);
}
return await productRequest;
};
return await Promise.all(normalizedItems.map(async (item) => {
const productId = toPositiveInteger(item?.product_id);
const embeddedProductId = toPositiveInteger(item?.product?.id);
if (!productId || productId === embeddedProductId) {
return item;
}
const canonicalProduct = await getCanonicalProduct(productId, item?.id);
if (!canonicalProduct) {
return item;
}
return {
...item,
product: {
...(item?.product || {}),
...canonicalProduct,
},
};
}));
};
export const loadOrderItems = async () => { export const loadOrderItems = async () => {
const normalizedOrderId = Number(order_id.value); const normalizedOrderId = Number(order_id.value);
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) { if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
@@ -1470,7 +1538,7 @@ export const loadOrderItems = async () => {
order_items.value = []; order_items.value = [];
return order_items.value; return order_items.value;
} }
order_items.value = response.data.data; order_items.value = await reconcileOrderItemProducts(response.data.data);
return order_items.value; return order_items.value;
}; };
@@ -5,7 +5,7 @@ import "@/components/displays/department/pos/PosDepartmentMVP.vue";
import "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosNavigation.vue"; import "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosNavigation.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {computed, onMounted, onUnmounted, ref, watch} from "vue"; import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import { selectCustomer, setOrderId, customer_name, showDeleteOrderDialog, invoiceAllOrdersIndividually, invoiceUsingStripe, setProductsCategory, order_items, customer_id, invoiceCollectionId, fetchAttachments, deleteAttachment, uploadAttachment, downloadAttachment, getAttachmentPreviewLink, attachments } from "@/components/shop/POSDepartmentProcess.vue"; import { setOrderId, applyOrderDetailsToPosState, customer_name, showDeleteOrderDialog, invoiceAllOrdersIndividually, invoiceUsingStripe, setProductsCategory, order_items, customer_id, invoiceCollectionId, fetchAttachments, deleteAttachment, uploadAttachment, downloadAttachment, getAttachmentPreviewLink, attachments, reconcileOrderItemProducts } from "@/components/shop/POSDepartmentProcess.vue";
import { getOrder } from "@/components/shop/Orders.vue"; import { getOrder } from "@/components/shop/Orders.vue";
import { getOrderItems } from "@/components/shop/OrdersItems.vue"; import { getOrderItems } from "@/components/shop/OrdersItems.vue";
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue"; import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
@@ -122,23 +122,27 @@ const loadOrder = async () => {
isLoading.value = false; isLoading.value = false;
return; return;
} }
order.value = response.data.data; const orderData = response.data.data;
order_items.value = response.data.includes.orderItems; // Get the order items const includes = response.data.includes ?? {};
customer.value = response.data.includes.customer; // Get the customer order.value = orderData;
cashier.value = response.data.includes.cashier; // Get the cashier
economicModule.value = response.data.includes.economicModuleOrders; // Get the economic module orders
stripeModule.value = response.data.includes.stripeModuleOrders; // Get the stripe module orders
closed_at.value = response.data.data.closed_at;
setOrderId(orderId.value, { setOrderId(orderId.value, {
departmentId: response.data.data.department_id, departmentId: orderData.department_id,
syncDepartmentWithSelection: false, syncDepartmentWithSelection: false,
loadItems: false, loadItems: false,
}); });
applyOrderDetailsToPosState(orderData, includes);
order_items.value = await reconcileOrderItemProducts(includes.orderItems, {
departmentId: orderData.department_id,
customerId: orderData.customer_id,
}); // Get the order items
customer.value = includes.customer; // Get the customer
cashier.value = includes.cashier; // Get the cashier
economicModule.value = includes.economicModuleOrders; // Get the economic module orders
stripeModule.value = includes.stripeModuleOrders; // Get the stripe module orders
closed_at.value = orderData.closed_at;
await fetchAttachments(orderId.value); await fetchAttachments(orderId.value);
isLoading.value = false; isLoading.value = false;
selectCustomer(customer.value.economic_customer); if (orderData.id) {
invoiceCollectionId.value = response.data.data.invoice_collection_id || null;
if (response.data.data.id) {
doesOrderExist.value = true; doesOrderExist.value = true;
} }
if (economicModule.value.invoice_draft_id) { if (economicModule.value.invoice_draft_id) {
@@ -171,7 +175,7 @@ const isInvoicedWithEconomic = () => {
const loadOrderItems = async () => { const loadOrderItems = async () => {
const response = await getOrderItems(orderId.value); const response = await getOrderItems(orderId.value);
order_items.value = response.data.data; order_items.value = await reconcileOrderItemProducts(response.data.data);
}; };
const attachWashCertificateToOrder = async () => { const attachWashCertificateToOrder = async () => {
+83
View File
@@ -814,6 +814,69 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(combinedMessages).not.toContain("Unhandled error during execution of setup function"); expect(combinedMessages).not.toContain("Unhandled error during execution of setup function");
}); });
test("reconciles a mismatched embedded product using the direct order response context", async ({ page }) => {
const baseFixture = createPosFixture();
const directOrderId = 54519;
const responseDepartmentId = 44;
const responseCustomerId = 12345679;
const canonicalProduct = {
...baseFixture.products.find((product) => Number(product.id) === 63),
name: "Canonical direct-order product",
};
const fixture = createPosFixture({
products: baseFixture.products.map((product) => (Number(product.id) === 63 ? canonicalProduct : product)),
ordersById: {
...baseFixture.ordersById,
[directOrderId]: {
...baseFixture.ordersById[54518],
id: directOrderId,
department_id: responseDepartmentId,
customer_id: responseCustomerId,
},
},
orderItemsByOrderId: {
...baseFixture.orderItemsByOrderId,
[directOrderId]: [
{
...baseFixture.orderItemsByOrderId[54518][0],
id: 9150,
order_id: directOrderId,
product_id: 63,
product: {
...baseFixture.products.find((product) => Number(product.id) === 53),
name: "Stale embedded order product",
},
},
],
},
attachmentsByOrderId: {
...baseFixture.attachmentsByOrderId,
[directOrderId]: [],
},
});
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeOperatorSession(page, "pos-direct-order-reconciliation-token");
const canonicalProductRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return request.method() === "GET" && url.pathname.endsWith("/products") && url.searchParams.get("id") === "63";
});
await openOrderDetail(page, directOrderId);
const requestUrl = new URL((await canonicalProductRequest).url());
expect(requestUrl.searchParams.get("department_id")).toBe(String(responseDepartmentId));
expect(requestUrl.searchParams.get("customer_id")).toBe(String(responseCustomerId));
expect(requestUrl.searchParams.get("final_price")).toBe("true");
await expect(page.getByTestId("pos-order-item-name-9150")).toContainText("Canonical direct-order product");
});
test("renders order detail metadata and item actions without the Excel export affordance", async ({ page }) => { test("renders order detail metadata and item actions without the Excel export affordance", async ({ page }) => {
await openOrderDetail(page); await openOrderDetail(page);
await expect(page.getByTestId("pos-order-header-export-actions")).toHaveCount(0); await expect(page.getByTestId("pos-order-header-export-actions")).toHaveCount(0);
@@ -2119,6 +2182,7 @@ test.describe("Admin POS wash certificate completion", () => {
id: 9802, id: 9802,
order_id: materialOrderId, order_id: materialOrderId,
related_item_id: 9801, related_item_id: 9801,
product: baseFixture.products.find((product) => Number(product.id) === 64),
}, },
], ],
}, },
@@ -2136,6 +2200,22 @@ test.describe("Admin POS wash certificate completion", () => {
}); });
await primeOperatorSession(page, "pos-material-sequencing-token"); await primeOperatorSession(page, "pos-material-sequencing-token");
const completionRequests: string[] = [];
const washCertificateRequests: string[] = [];
page.on("request", (request) => {
if (request.method() !== "POST") {
return;
}
const pathname = new URL(request.url()).pathname;
if (pathname.endsWith("/orders/mark_as_completed")) {
completionRequests.push(request.url());
}
if (pathname.endsWith("/order/wash-certificate")) {
washCertificateRequests.push(request.url());
}
});
await page.goto(`/admin/12/modules/pos?id=${materialOrderId}&customer_id=12345679&step=3`); await page.goto(`/admin/12/modules/pos?id=${materialOrderId}&customer_id=12345679&step=3`);
await expect(page.getByTestId("pos-step-3")).toBeVisible(); await expect(page.getByTestId("pos-step-3")).toBeVisible();
@@ -2162,6 +2242,9 @@ test.describe("Admin POS wash certificate completion", () => {
{ timeout: 10_000 } { timeout: 10_000 }
) )
.toBe(true); .toBe(true);
await expect(page.locator(".swal2-popup:visible")).toContainText(/Fuldført|Completed/i);
expect(completionRequests).toHaveLength(1);
expect(washCertificateRequests).toHaveLength(0);
}); });
}); });
+21 -12
View File
@@ -346,6 +346,13 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
}); });
await primeOperatorSession(page, "pos-only-tankcleaning-token"); 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 customer = fixture.customersByNumber[12345679];
const customerAttributesResponse = page.waitForResponse((response) => { const customerAttributesResponse = page.waitForResponse((response) => {
return ( return (
@@ -359,12 +366,13 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click(); 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-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await page.getByTestId("pos-product-card-53").first().click(); const restrictedProductCard = page.getByTestId("pos-product-card-53").first();
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled(); await restrictedProductCard.click();
await page.getByTestId("pos-add-to-cart-restriction-tooltip-53").hover(); await expect(restrictedProductCard).not.toHaveClass(/is-selected/);
await expect(page.locator(".tooltip-content:visible").last()).toContainText( await expect(page.locator(".swal2-popup:visible")).toContainText("Produktet er ikke tilladt for denne kunde");
"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 page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible(); await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
@@ -377,6 +385,7 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
await page.getByTestId("pos-add-to-cart-66").first().click(); await page.getByTestId("pos-add-to-cart-66").first().click();
const request = await addRequest; const request = await addRequest;
expect(request.postDataJSON().product_id).toBe(66); 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 ({ test("exact customer-rule configuration disables desktop add-on controls by option_id without placeholder rows", async ({
@@ -536,12 +545,12 @@ test("desktop remains fail-closed when an active product rule has a malformed re
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT }); await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await expect(page.getByTestId("pos-customer-restrictions-load-error")).toBeVisible(); await expect(page.getByTestId("pos-customer-restrictions-load-error")).toBeVisible();
await page.getByTestId("pos-product-card-53").first().click(); const blockedProductCard = page.getByTestId("pos-product-card-53").first();
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled(); await blockedProductCard.click();
await page await expect(blockedProductCard).not.toHaveClass(/is-selected/);
.getByTestId("pos-add-to-cart-53") await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i);
.first() await page.locator(".swal2-confirm:visible").click();
.evaluate((button) => button.click()); await expect(page.locator('[data-testid^="pos-add-to-cart-"]:visible')).toHaveCount(0);
expect(orderItemPosts).toEqual([]); expect(orderItemPosts).toEqual([]);
}); });
+24 -7
View File
@@ -179,18 +179,35 @@ test("[BOOKINGS][User][Rules] should block restricted products but allow an exac
}); });
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false }); await goToBookingProductSelectionStepWithOptions(page, bookingTestData, { selectInitialProduct: false });
const restrictedCard = page.locator('[data-testid="pos-product-card-53"]:visible').first(); const restrictedMutationRequests: Array<Record<string, unknown>> = [];
await expect(restrictedCard).toHaveAttribute("data-restricted", "true"); page.on("request", (request) => {
await expect(restrictedCard.getByTestId("pos-product-restriction-53")).toContainText(/Ikke tilgængelig|Unavailable/i); const pathname = new URL(request.url()).pathname;
await restrictedCard.press("Enter"); if (request.method() === "POST" && (pathname.endsWith("/order/items") || pathname.endsWith("/order-bookings"))) {
await expect(restrictedCard).not.toHaveClass(/is-selected/); restrictedMutationRequests.push(request.postDataJSON() as Record<string, unknown>);
await expect(page.locator(".swal2-popup:visible")).toContainText(/Kunderegel|Customer rule/i); }
await page.locator(".swal2-confirm:visible").click(); });
const allowedCard = page.locator('[data-testid="pos-product-card-54"]:visible').first(); const allowedCard = page.locator('[data-testid="pos-product-card-54"]:visible').first();
await expect(allowedCard).toHaveAttribute("data-restricted", "false"); await expect(allowedCard).toHaveAttribute("data-restricted", "false");
await allowedCard.click(); await allowedCard.click();
await expect(allowedCard).toHaveClass(/is-selected/); 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 ({ test("[BOOKINGS][User][Rules] should block an interior wash when its required certificate is restricted", async ({
+9
View File
@@ -180,6 +180,15 @@ describe("Playwright PR mapping", () => {
expect(specsFor("src/components/shop/POSDepartmentProcess.vue")).toContain("tests/e2e/admin-pos-orders.spec.ts"); expect(specsFor("src/components/shop/POSDepartmentProcess.vue")).toContain("tests/e2e/admin-pos-orders.spec.ts");
}); });
it("maps the shared POS product selector to POS and booking customer-rule coverage", () => {
const specs = specsFor("src/components/forms/department/pos/SelectProductsFormPOS.vue");
const mapping = sourceMappings.find((candidate) => candidate.name === "shared-pos-product-selection");
expect(specs).toContain("tests/e2e/pos-customer-rules.spec.js");
expect(specs).toContain("tests/e2e/userBookWash.spec.ts");
expect(mapping?.projects).toEqual(["chromium-desktop", "chromium-mobile"]);
});
it("maps global customer rule configuration changes to its editor coverage", () => { it("maps global customer rule configuration changes to its editor coverage", () => {
expect(specsFor("src/views/dashboards/superUserDashboard/CustomerRuleProductRestrictions.vue")).toContain( expect(specsFor("src/views/dashboards/superUserDashboard/CustomerRuleProductRestrictions.vue")).toContain(
"tests/e2e/superuser-customer-rules.spec.ts" "tests/e2e/superuser-customer-rules.spec.ts"
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { canonicalProductLookup } = vi.hoisted(() => ({
canonicalProductLookup: vi.fn(),
}));
vi.mock("@/components/numberplatescanners/Scans.vue", () => ({
getScansDepartmentPagination: vi.fn(),
}));
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
clearErrors: vi.fn(),
parseError: vi.fn(),
}));
vi.mock("@/components/shop/CustomerNotes.vue", () => ({
createNote: vi.fn(),
deleteNote: vi.fn(),
getNotes: vi.fn(),
}));
vi.mock("@/components/shop/OrdersItems.vue", () => ({
createOrderItem: vi.fn(),
getOrderItems: vi.fn(),
removeOrderItem: vi.fn(),
}));
vi.mock("@/components/shop/CustomerAttributes.vue", () => ({
getAttributes: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
functions: {},
objects: {
products: {
get: {
single: canonicalProductLookup,
},
},
},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
}));
vi.mock("@/components/displays/department/pos/utils/washCertificate.js", () => ({
doesOrderContainWashCertificateProduct: vi.fn(() => false),
}));
vi.mock("@/features/customer/customerProductRules.js", () => ({
getCustomerProductRestriction: vi.fn(),
getProductCategoryRestrictionForCustomer: vi.fn(),
hasValidCustomerProductRestrictionContract: vi.fn(() => true),
isProductCategoryRestrictedForCustomer: vi.fn(() => false),
}));
vi.mock("sweetalert2", () => ({
default: { fire: vi.fn() },
}));
import { customer_id, department_id, reconcileOrderItemProducts } from "@/components/shop/POSDepartmentProcess.vue";
const inconsistentItem = (overrides = {}) => ({
id: 9101,
order_id: 54518,
product_id: 63,
product: {
id: 53,
name: "Stale embedded product",
embedded_only: "preserve me",
},
quantity: 2,
price: 777,
notes: "Order-item note",
...overrides,
});
describe("reconcileOrderItemProducts", () => {
beforeEach(() => {
canonicalProductLookup.mockReset();
canonicalProductLookup.mockResolvedValue({
id: 63,
name: "Canonical product",
price: 399,
canonical_only: true,
});
department_id.value = 12;
customer_id.value = 12345679;
});
it("uses explicit order context instead of stale global POS context", async () => {
await reconcileOrderItemProducts([inconsistentItem()], {
departmentId: 44,
customerId: 87654321,
});
expect(canonicalProductLookup).toHaveBeenCalledOnce();
expect(canonicalProductLookup).toHaveBeenCalledWith(63, {
department_id: 44,
customer_id: 87654321,
category_id: null,
final_price: true,
});
});
it("merges canonical product data without losing order-item or embedded product fields", async () => {
const [result] = await reconcileOrderItemProducts([inconsistentItem()]);
expect(result).toMatchObject({
id: 9101,
order_id: 54518,
product_id: 63,
quantity: 2,
price: 777,
notes: "Order-item note",
product: {
id: 63,
name: "Canonical product",
price: 399,
embedded_only: "preserve me",
canonical_only: true,
},
});
});
it("fetches a missing canonical product only once for duplicate product IDs", async () => {
const results = await reconcileOrderItemProducts([
inconsistentItem({ id: 9101 }),
inconsistentItem({ id: 9102, product: { id: 54, name: "Another stale product" } }),
]);
expect(canonicalProductLookup).toHaveBeenCalledOnce();
expect(results.map((item) => item.product.id)).toEqual([63, 63]);
expect(results.map((item) => item.product.name)).toEqual(["Canonical product", "Canonical product"]);
});
it("does not reuse a mismatched embedded product as canonical data for another item", async () => {
const [result] = await reconcileOrderItemProducts([
inconsistentItem({ id: 9101 }),
inconsistentItem({
id: 9102,
product_id: 64,
product: { id: 63, name: "Stale product from another mismatched item" },
}),
]);
expect(canonicalProductLookup).toHaveBeenCalledWith(63, expect.any(Object));
expect(result.product.name).toBe("Canonical product");
});
it("does not fall back to stale global context when explicit order context is empty", async () => {
await reconcileOrderItemProducts([inconsistentItem()], {
departmentId: null,
customerId: null,
});
expect(canonicalProductLookup).toHaveBeenCalledWith(63, {
department_id: null,
customer_id: null,
category_id: null,
final_price: true,
});
});
it.each([undefined, null, {}, "not-an-array"])(
"normalizes malformed item input %# to an empty list",
async (items) => {
await expect(reconcileOrderItemProducts(items)).resolves.toEqual([]);
expect(canonicalProductLookup).not.toHaveBeenCalled();
}
);
});