## What changed - load customer product rules for the booking customer and keep selection fail-closed while they resolve - prevent restricted products, add-ons, pickup, and wash certificates from remaining selected or being submitted - improve product-grid containment, selected add-on styling, unavailable messaging, and tablet/mobile rendering - preserve booking details when the API rejects stale restricted items - add unit and Playwright coverage for loading, retry, exact restrictions, automatic products, layout, and stale backend recovery ## Why The customer booking flow could use stale or unrelated POS customer-rule state, expose restricted items, and lose useful form state after a server rejection. The desktop product area also overflowed and switched to an inconsistent compact layout after selection. ## Validation - desktop booking Playwright suite: 12 passed - mobile booking Playwright suite: 4 passed, 8 expected desktop-only skips - relevant unit tests: 35 passed - ESLint and i18n checks - production Vite build ## Related backend PR The coordinated API PR enforces the same rule at the write boundary.
1022 lines
31 KiB
JavaScript
1022 lines
31 KiB
JavaScript
// @vitest-environment jsdom
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getOrderItems: vi.fn(),
|
|
createOrderItem: vi.fn(),
|
|
removeOrderItem: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("axios", () => ({
|
|
default: {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/numberplatescanners/Scans.vue", () => ({
|
|
getScansDepartmentPagination: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("vue-router", () => ({
|
|
useRouter: () => ({
|
|
currentRoute: {
|
|
value: {
|
|
params: {
|
|
departmentId: "1",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
|
|
parseError: vi.fn(),
|
|
clearErrors: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/components/shop/CustomerNotes.vue", () => ({
|
|
getNotes: vi.fn(),
|
|
createNote: vi.fn(),
|
|
deleteNote: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/components/shop/OrdersItems.vue", () => ({
|
|
createOrderItem: mocks.createOrderItem,
|
|
getOrderItems: mocks.getOrderItems,
|
|
removeOrderItem: mocks.removeOrderItem,
|
|
}));
|
|
|
|
vi.mock("@/components/shop/CustomerAttributes.vue", () => ({
|
|
getAttributes: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|
SessionUser: {
|
|
request: vi.fn(),
|
|
hasPermission: vi.fn(() => true),
|
|
functions: {
|
|
redirectTo: {
|
|
department: vi.fn(),
|
|
},
|
|
getDepartmentIdFromUrl: vi.fn(),
|
|
},
|
|
objects: {
|
|
global: {
|
|
language: {
|
|
possible_duplicates: "Possible duplicates",
|
|
},
|
|
},
|
|
orders: {
|
|
meta: {
|
|
endpoint: "/orders",
|
|
},
|
|
functions: {
|
|
mark_as_completed: vi.fn(),
|
|
fetchAttachments: vi.fn(),
|
|
removeAttachment: vi.fn(),
|
|
uploadAttachment: vi.fn(),
|
|
downloadAttachment: vi.fn(),
|
|
get_department_id: vi.fn(),
|
|
},
|
|
get: {
|
|
single: vi.fn(),
|
|
},
|
|
set: {
|
|
booking_id: vi.fn(),
|
|
department_id: vi.fn(),
|
|
notes: vi.fn(),
|
|
po: vi.fn(),
|
|
reference: vi.fn(),
|
|
reg_1: vi.fn(),
|
|
reg_2: vi.fn(),
|
|
reg_3: vi.fn(),
|
|
safety_seal: vi.fn(),
|
|
},
|
|
},
|
|
order_bookings: {
|
|
meta: {
|
|
endpoint: "/order_bookings",
|
|
},
|
|
get: {
|
|
single: vi.fn(),
|
|
},
|
|
set: {
|
|
order_id: vi.fn(),
|
|
},
|
|
},
|
|
products: {
|
|
get: {
|
|
all: vi.fn(),
|
|
single: vi.fn(),
|
|
},
|
|
},
|
|
vehicles: {
|
|
meta: {
|
|
endpoint: "/vehicles",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
|
authenticatedRequest: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("sweetalert2", () => ({
|
|
default: {
|
|
fire: vi.fn(() => Promise.resolve({ isConfirmed: false })),
|
|
showValidationMessage: vi.fn(),
|
|
isLoading: vi.fn(() => false),
|
|
},
|
|
}));
|
|
|
|
import axios from "axios";
|
|
import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
|
|
import { getNotes } from "@/components/shop/CustomerNotes.vue";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
|
import {
|
|
clearActivePosOrderContext,
|
|
clearCustomerSelection,
|
|
clearPendingOrderItems,
|
|
clearStoredPosOrderId,
|
|
createOrder,
|
|
completed_at,
|
|
customer_data,
|
|
customer_attributes,
|
|
customer_attributes_customer_number,
|
|
customer_attributes_status,
|
|
customer_id,
|
|
customer_name,
|
|
department_id,
|
|
ensureVehiclePlateBookingsLoaded,
|
|
getAddonRestriction,
|
|
getVehiclePlateBookings,
|
|
getProductRestriction,
|
|
invoiceCollectionId,
|
|
loadCustomerAttributes,
|
|
loadOrderItems,
|
|
nextStep,
|
|
nextStepDelay,
|
|
order_id,
|
|
order_items,
|
|
hydrateSelectedOrderBookingForDesktop,
|
|
order_notes,
|
|
order_po,
|
|
order_safety_seal,
|
|
reference,
|
|
reg_1,
|
|
reg_2,
|
|
reg_3,
|
|
pendingBookings,
|
|
previousStep,
|
|
registerPosStepSaveBarrier,
|
|
retryCustomerAttributes,
|
|
restoreStoredPosOrderId,
|
|
saveOrderMetadataField,
|
|
searchAndSelectCustomer,
|
|
setOrderId,
|
|
showFakeCreateOrderItem,
|
|
scan_data,
|
|
scans,
|
|
selectedOrderBookingId,
|
|
step,
|
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
|
|
|
describe("POSDepartmentProcess order metadata persistence", () => {
|
|
beforeEach(() => {
|
|
window.history.replaceState({}, "", "/admin/12/modules/pos?step=2");
|
|
clearActivePosOrderContext({ clearStep: true });
|
|
localStorage.setItem("token", "test-token");
|
|
nextStepDelay.value = 0;
|
|
order_id.value = 54518;
|
|
customer_id.value = 999;
|
|
customer_name.value = "Card Terminal Customer";
|
|
customer_data.value = {};
|
|
department_id.value = 12;
|
|
step.value = 2;
|
|
Object.values(SessionUser.objects.orders.set).forEach((setter) => setter.mockReset?.());
|
|
});
|
|
|
|
it("applies saved metadata responses to shared POS state", async () => {
|
|
SessionUser.objects.orders.set.reference.mockResolvedValueOnce({
|
|
data: {
|
|
data: {
|
|
id: 54518,
|
|
customer_id: 999,
|
|
department_id: 12,
|
|
reference: "STEP-2-REF",
|
|
notes: "Visible note",
|
|
po: "PO-STEP-2",
|
|
safety_seal: "SEAL-1",
|
|
reg_1: "aa 12345",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: 200,
|
|
completed_at: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(saveOrderMetadataField("reference", "STEP-2-REF", 54518)).resolves.toBe("STEP-2-REF");
|
|
|
|
expect(SessionUser.objects.orders.set.reference).toHaveBeenCalledWith(54518, "STEP-2-REF");
|
|
expect(reference.value).toBe("STEP-2-REF");
|
|
expect(order_notes.value).toBe("Visible note");
|
|
expect(order_po.value).toBe("PO-STEP-2");
|
|
expect(order_safety_seal.value).toBe("SEAL-1");
|
|
expect(reg_1.value).toBe("AA12345");
|
|
});
|
|
|
|
it("preserves registration input formatting for the save request and normalizes shared state", async () => {
|
|
SessionUser.objects.orders.set.reg_1.mockResolvedValueOnce({
|
|
data: {
|
|
data: {
|
|
id: 54518,
|
|
reg_1: "CD1234",
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(saveOrderMetadataField("reg_1", "cd-12 34", 54518)).resolves.toBe("CD1234");
|
|
|
|
expect(SessionUser.objects.orders.set.reg_1).toHaveBeenCalledWith(54518, "CD-12 34");
|
|
expect(reg_1.value).toBe("CD1234");
|
|
});
|
|
|
|
it("waits for registered field saves before moving from step 2 to step 3", async () => {
|
|
let resolveBarrier;
|
|
const barrier = vi.fn(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveBarrier = () => {
|
|
reference.value = "BARRIER-SAVED";
|
|
resolve();
|
|
};
|
|
})
|
|
);
|
|
const unregister = registerPosStepSaveBarrier(barrier);
|
|
|
|
try {
|
|
const moveToNextStep = nextStep({ isMobile: false, orderCreation: false });
|
|
await Promise.resolve();
|
|
|
|
expect(barrier).toHaveBeenCalledTimes(1);
|
|
expect(window.location.search).toBe("?step=2");
|
|
|
|
resolveBarrier();
|
|
await moveToNextStep;
|
|
|
|
expect(reference.value).toBe("BARRIER-SAVED");
|
|
expect(window.location.search).toContain("step=3");
|
|
expect(window.location.search).toContain("id=54518");
|
|
expect(window.location.search).toContain("customer_id=999");
|
|
} finally {
|
|
unregister();
|
|
nextStepDelay.value = 0;
|
|
}
|
|
});
|
|
|
|
it("waits for registered field saves before moving backward", async () => {
|
|
step.value = 3;
|
|
window.history.replaceState({}, "", "/admin/12/modules/pos?id=54518&customer_id=999&step=3");
|
|
const barrier = vi.fn(async () => {
|
|
order_po.value = "PO-BACK-SAVED";
|
|
});
|
|
const unregister = registerPosStepSaveBarrier(barrier);
|
|
|
|
try {
|
|
await previousStep();
|
|
|
|
expect(barrier).toHaveBeenCalledTimes(1);
|
|
expect(order_po.value).toBe("PO-BACK-SAVED");
|
|
expect(step.value).toBe(2);
|
|
expect(window.location.search).toContain("step=2");
|
|
} finally {
|
|
unregister();
|
|
nextStepDelay.value = 0;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.ensureVehiclePlateBookingsLoaded", () => {
|
|
beforeEach(() => {
|
|
department_id.value = 531;
|
|
SessionUser.request.mockReset();
|
|
});
|
|
|
|
it("uses exact-plate cache even when forced again", async () => {
|
|
SessionUser.request.mockImplementation(async (_endpoint, _method, params = {}) => {
|
|
const filters = String(params.filters || "");
|
|
if (filters.includes("reg_1:EC21234")) {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
id: 9910,
|
|
department: 531,
|
|
reg_1: "EC21234",
|
|
reg_2: "",
|
|
order_id: null,
|
|
datetime: "2026-03-20T08:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
return { data: { data: [] } };
|
|
});
|
|
|
|
await expect(ensureVehiclePlateBookingsLoaded("EC21234", { force: true })).resolves.toEqual([
|
|
expect.objectContaining({ id: 9910 }),
|
|
]);
|
|
await expect(ensureVehiclePlateBookingsLoaded("EC21234", { force: true })).resolves.toEqual([
|
|
expect.objectContaining({ id: 9910 }),
|
|
]);
|
|
|
|
expect(getVehiclePlateBookings("EC21234")).toEqual([expect.objectContaining({ id: 9910 })]);
|
|
expect(SessionUser.request).toHaveBeenCalledTimes(2);
|
|
expect(SessionUser.request).toHaveBeenNthCalledWith(
|
|
1,
|
|
"/order_bookings",
|
|
"GET",
|
|
expect.objectContaining({ filters: "department:531,reg_1:EC21234,order_id:is null", limit: 250 })
|
|
);
|
|
expect(SessionUser.request).toHaveBeenNthCalledWith(
|
|
2,
|
|
"/order_bookings",
|
|
"GET",
|
|
expect.objectContaining({ filters: "department:531,reg_2:EC21234,order_id:is null", limit: 250 })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.loadOrderItems", () => {
|
|
beforeEach(() => {
|
|
order_id.value = null;
|
|
order_items.value = [];
|
|
mocks.getOrderItems.mockReset();
|
|
});
|
|
|
|
it("skips loading when order id is missing or invalid", async () => {
|
|
order_id.value = null;
|
|
await expect(loadOrderItems()).resolves.toEqual([]);
|
|
|
|
order_id.value = 0;
|
|
await expect(loadOrderItems()).resolves.toEqual([]);
|
|
|
|
order_id.value = "abc";
|
|
await expect(loadOrderItems()).resolves.toEqual([]);
|
|
|
|
expect(mocks.getOrderItems).not.toHaveBeenCalled();
|
|
expect(order_items.value).toEqual([]);
|
|
});
|
|
|
|
it("loads order items for a valid order id", async () => {
|
|
order_id.value = "9201";
|
|
mocks.getOrderItems.mockResolvedValue({
|
|
data: {
|
|
data: [{ id: 1, price: 10, quantity: 1 }],
|
|
},
|
|
});
|
|
|
|
await expect(loadOrderItems()).resolves.toEqual([{ id: 1, price: 10, quantity: 1 }]);
|
|
|
|
expect(mocks.getOrderItems).toHaveBeenCalledWith(9201);
|
|
expect(order_items.value).toEqual([{ id: 1, price: 10, quantity: 1 }]);
|
|
});
|
|
|
|
it("does not create pending order rows without product details", () => {
|
|
expect(showFakeCreateOrderItem(63, 1, 0)).toBeNull();
|
|
expect(showFakeCreateOrderItem({ id: 63 }, 1, 0)).toBeNull();
|
|
expect(order_items.value).toEqual([]);
|
|
});
|
|
|
|
it("clears pending rows before replacing them with reloaded order items", async () => {
|
|
order_id.value = "9201";
|
|
const pendingProduct = { id: 63, name: "Indvendig vask Forvogn", price: 399 };
|
|
const realOrderItem = {
|
|
id: 9801,
|
|
product_id: 63,
|
|
product: pendingProduct,
|
|
quantity: 1,
|
|
price: 399,
|
|
};
|
|
mocks.getOrderItems.mockResolvedValue({
|
|
data: {
|
|
data: [realOrderItem],
|
|
},
|
|
});
|
|
|
|
const pendingItem = showFakeCreateOrderItem(pendingProduct, 1, 399);
|
|
expect(pendingItem).toEqual(expect.objectContaining({ _pending: true, product: pendingProduct }));
|
|
expect(order_items.value).toHaveLength(1);
|
|
|
|
clearPendingOrderItems();
|
|
expect(order_items.value).toEqual([]);
|
|
|
|
await expect(loadOrderItems()).resolves.toEqual([realOrderItem]);
|
|
expect(order_items.value).toEqual([realOrderItem]);
|
|
});
|
|
|
|
it("falls back to empty list when response payload is malformed", async () => {
|
|
order_id.value = 9201;
|
|
mocks.getOrderItems.mockResolvedValue({
|
|
data: {
|
|
data: null,
|
|
},
|
|
});
|
|
|
|
await expect(loadOrderItems()).resolves.toEqual([]);
|
|
|
|
expect(mocks.getOrderItems).toHaveBeenCalledWith(9201);
|
|
expect(order_items.value).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.setOrderId", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
order_id.value = null;
|
|
order_items.value = [];
|
|
mocks.getOrderItems.mockReset();
|
|
});
|
|
|
|
it("can skip loading order items when the caller already has them", () => {
|
|
setOrderId(9201, { loadItems: false });
|
|
|
|
expect(order_id.value).toBe(9201);
|
|
expect(mocks.getOrderItems).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.searchAndSelectCustomer", () => {
|
|
beforeEach(() => {
|
|
clearCustomerSelection();
|
|
authenticatedRequest.mockReset();
|
|
getNotes.mockReset();
|
|
getNotes.mockResolvedValue({ data: { data: [] } });
|
|
getAttributes.mockReset();
|
|
getAttributes.mockResolvedValue({ data: { success: true, data: [] } });
|
|
});
|
|
|
|
const mockCustomerResponse = (customerNumber = 12345679, name = "Pleno Logistics") => ({
|
|
data: {
|
|
data: {
|
|
customer_number: customerNumber,
|
|
customer_name: name,
|
|
economic_customer: {
|
|
customerNumber,
|
|
name,
|
|
barred: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
it("returns the already selected customer without a duplicate lookup", async () => {
|
|
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
|
|
|
|
const selectedCustomer = await searchAndSelectCustomer(12345679);
|
|
const cachedCustomer = await searchAndSelectCustomer(12345679);
|
|
|
|
expect(authenticatedRequest).toHaveBeenCalledTimes(1);
|
|
expect(authenticatedRequest).toHaveBeenCalledWith("/users/customer?customer_number=12345679", "GET");
|
|
expect(cachedCustomer).toBe(customer_data.value);
|
|
expect(cachedCustomer).toEqual(selectedCustomer);
|
|
expect(customer_id.value).toBe(12345679);
|
|
expect(customer_name.value).toBe("Pleno Logistics");
|
|
});
|
|
|
|
it("shares concurrent lookups for the same customer number", async () => {
|
|
let resolveRequest;
|
|
authenticatedRequest.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveRequest = resolve;
|
|
})
|
|
);
|
|
|
|
const firstLookup = searchAndSelectCustomer(12345679);
|
|
const secondLookup = searchAndSelectCustomer(12345679);
|
|
resolveRequest(mockCustomerResponse());
|
|
|
|
const [firstCustomer, secondCustomer] = await Promise.all([firstLookup, secondLookup]);
|
|
|
|
expect(authenticatedRequest).toHaveBeenCalledTimes(1);
|
|
expect(firstCustomer).toEqual(secondCustomer);
|
|
expect(customer_id.value).toBe(12345679);
|
|
});
|
|
|
|
it("performs a new lookup when forceRefresh is requested", async () => {
|
|
authenticatedRequest
|
|
.mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics"))
|
|
.mockResolvedValueOnce(mockCustomerResponse(12345679, "Pleno Logistics Updated"));
|
|
|
|
await searchAndSelectCustomer(12345679);
|
|
const refreshedCustomer = await searchAndSelectCustomer(12345679, { forceRefresh: true });
|
|
|
|
expect(authenticatedRequest).toHaveBeenCalledTimes(2);
|
|
expect(refreshedCustomer.name).toBe("Pleno Logistics Updated");
|
|
expect(customer_name.value).toBe("Pleno Logistics Updated");
|
|
});
|
|
|
|
it("fails closed while customer restrictions load and resolves exact product IDs when ready", async () => {
|
|
let resolveAttributes;
|
|
getAttributes.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveAttributes = resolve;
|
|
})
|
|
);
|
|
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
|
|
|
|
await searchAndSelectCustomer(12345679);
|
|
|
|
expect(customer_attributes_status.value).toBe("loading");
|
|
expect(getProductRestriction({ id: 53 })).toMatchObject({
|
|
restricted: true,
|
|
messageKey: "pos.restrictions.loading",
|
|
});
|
|
|
|
resolveAttributes({
|
|
data: {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 1,
|
|
attribute: "onlyTankCleaning",
|
|
product_restriction: {
|
|
version: 1,
|
|
disabled_product_ids: [53],
|
|
collections: [],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("ready"));
|
|
expect(getProductRestriction({ id: "53" }).restricted).toBe(true);
|
|
expect(getProductRestriction({ id: 63 }).restricted).toBe(false);
|
|
});
|
|
|
|
it("fails closed when an active product-impact rule lacks its structured restriction payload", async () => {
|
|
getAttributes.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
data: [{ id: 1, attribute: "restrictSpotFree" }],
|
|
},
|
|
});
|
|
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
|
|
|
|
await searchAndSelectCustomer(12345679);
|
|
|
|
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("error"));
|
|
expect(customer_attributes.value).toEqual([]);
|
|
expect(getProductRestriction({ id: 23 })).toMatchObject({
|
|
restricted: true,
|
|
messageKey: "pos.restrictions.load_failed",
|
|
});
|
|
});
|
|
|
|
it("ignores a late customer-attribute response after the selected customer changes", async () => {
|
|
let resolveFirstAttributes;
|
|
getAttributes
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveFirstAttributes = resolve;
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({ data: { success: true, data: [] } });
|
|
authenticatedRequest
|
|
.mockResolvedValueOnce(mockCustomerResponse(12345679, "First customer"))
|
|
.mockResolvedValueOnce(mockCustomerResponse(12345680, "Second customer"));
|
|
|
|
await searchAndSelectCustomer(12345679);
|
|
await searchAndSelectCustomer(12345680);
|
|
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("ready"));
|
|
|
|
resolveFirstAttributes({
|
|
data: {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 2,
|
|
attribute: "onlyTankCleaning",
|
|
product_restriction: { version: 1, disabled_product_ids: [53], collections: [] },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(customer_id.value).toBe(12345680);
|
|
expect(customer_attributes.value).toEqual([]);
|
|
expect(getProductRestriction({ id: 53 }).restricted).toBe(false);
|
|
});
|
|
|
|
it("loads restrictions for an explicit customer when the POS-global customer is blank", async () => {
|
|
let resolveAttributes;
|
|
getAttributes.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveAttributes = resolve;
|
|
})
|
|
);
|
|
|
|
customer_id.value = "";
|
|
const loadRequest = loadCustomerAttributes(12345679);
|
|
|
|
expect(getAttributes).toHaveBeenCalledWith(12345679);
|
|
expect(customer_attributes_status.value).toBe("loading");
|
|
expect(customer_attributes_customer_number.value).toBeNull();
|
|
expect(getProductRestriction({ id: 53 }, { customerNumber: 12345679 })).toMatchObject({
|
|
restricted: true,
|
|
messageKey: "pos.restrictions.loading",
|
|
});
|
|
|
|
resolveAttributes({
|
|
data: {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 1,
|
|
attribute: "restrictSpotFree",
|
|
product_restriction: { version: 1, disabled_product_ids: [53], collections: [] },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await loadRequest;
|
|
|
|
expect(customer_id.value).toBe("");
|
|
expect(customer_attributes_status.value).toBe("ready");
|
|
expect(customer_attributes_customer_number.value).toBe(12345679);
|
|
expect(getProductRestriction({ id: 53 }, { customerNumber: 12345679 }).restricted).toBe(true);
|
|
expect(getAddonRestriction({ option_id: 53 }, { customerNumber: 12345679 }).restricted).toBe(true);
|
|
});
|
|
|
|
it("retries an explicit target and then reuses the last requested target", async () => {
|
|
getAttributes
|
|
.mockRejectedValueOnce(new Error("attributes unavailable"))
|
|
.mockResolvedValueOnce({ data: { success: true, data: [] } })
|
|
.mockResolvedValueOnce({ data: { success: true, data: [] } });
|
|
|
|
customer_id.value = "";
|
|
await loadCustomerAttributes(12345679);
|
|
expect(customer_attributes_status.value).toBe("error");
|
|
|
|
await retryCustomerAttributes(12345680);
|
|
expect(customer_attributes_status.value).toBe("ready");
|
|
expect(customer_attributes_customer_number.value).toBe(12345680);
|
|
|
|
await retryCustomerAttributes();
|
|
expect(getAttributes.mock.calls.map(([customerNumber]) => customerNumber)).toEqual([12345679, 12345680, 12345680]);
|
|
expect(customer_attributes_customer_number.value).toBe(12345680);
|
|
});
|
|
|
|
it("ignores stale explicit-target responses without consulting the POS-global customer", async () => {
|
|
let resolveFirstAttributes;
|
|
getAttributes
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveFirstAttributes = resolve;
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 2,
|
|
attribute: "restrictSpotFree",
|
|
product_restriction: { version: 1, disabled_product_ids: [63], collections: [] },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
customer_id.value = "";
|
|
const firstRequest = loadCustomerAttributes(12345679);
|
|
await loadCustomerAttributes(12345680);
|
|
|
|
resolveFirstAttributes({
|
|
data: {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: 1,
|
|
attribute: "restrictSpotFree",
|
|
product_restriction: { version: 1, disabled_product_ids: [53], collections: [] },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await firstRequest;
|
|
|
|
expect(customer_id.value).toBe("");
|
|
expect(customer_attributes_customer_number.value).toBe(12345680);
|
|
expect(getProductRestriction({ id: 63 }, { customerNumber: 12345680 }).restricted).toBe(true);
|
|
expect(getProductRestriction({ id: 53 }, { customerNumber: 12345680 }).restricted).toBe(false);
|
|
expect(getProductRestriction({ id: 63 }, { customerNumber: 12345679 })).toMatchObject({
|
|
restricted: true,
|
|
messageKey: "pos.restrictions.loading",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.hydrateSelectedOrderBookingForDesktop", () => {
|
|
beforeEach(() => {
|
|
order_id.value = 51207;
|
|
customer_id.value = 12345679;
|
|
customer_name.value = "";
|
|
department_id.value = 2;
|
|
reference.value = "";
|
|
order_notes.value = "";
|
|
order_po.value = "";
|
|
reg_1.value = "";
|
|
reg_2.value = "";
|
|
order_items.value = [];
|
|
selectedOrderBookingId.value = 7001;
|
|
pendingBookings.value = [];
|
|
mocks.getOrderItems.mockReset();
|
|
mocks.createOrderItem.mockReset();
|
|
SessionUser.objects.products.get.single.mockReset();
|
|
SessionUser.objects.products.get.all.mockReset();
|
|
SessionUser.objects.orders.set.reference.mockReset();
|
|
SessionUser.objects.orders.set.reg_1.mockReset();
|
|
SessionUser.objects.orders.set.reg_2.mockReset();
|
|
SessionUser.objects.orders.set.notes.mockReset();
|
|
SessionUser.objects.orders.set.po.mockReset();
|
|
SessionUser.objects.order_bookings.get.single.mockReset();
|
|
});
|
|
|
|
it("hydrates booking items without forwarding booking-controlled prices", async () => {
|
|
pendingBookings.value = [
|
|
{
|
|
id: 7001,
|
|
customer_number: 12345679,
|
|
reference: "BOOK-7001",
|
|
reg_1: "AB12345",
|
|
reg_2: "CD12345",
|
|
notes: "Booking notes",
|
|
po: "PO-7001",
|
|
items: [
|
|
{ id: 10, price: 1, quantity: 1 },
|
|
{ id: 20, price: 2, quantity: 3, notes: "Addon note" },
|
|
],
|
|
},
|
|
];
|
|
mocks.getOrderItems.mockResolvedValueOnce({ data: { data: [] } }).mockResolvedValueOnce({ data: { data: [] } });
|
|
SessionUser.objects.products.get.single.mockResolvedValue({ id: 10, is_wash: true, price: 500 });
|
|
mocks.createOrderItem.mockResolvedValueOnce({ data: { data: { id: 9001 } } }).mockResolvedValueOnce({});
|
|
|
|
await expect(hydrateSelectedOrderBookingForDesktop()).resolves.toBe(true);
|
|
|
|
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500);
|
|
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500);
|
|
expect(SessionUser.objects.products.get.single).toHaveBeenCalledWith(10, {
|
|
department_id: 2,
|
|
customer_id: 12345679,
|
|
category_id: null,
|
|
final_price: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.clearActivePosOrderContext", () => {
|
|
it("clears active order, customer, metadata and stored mobile order id", () => {
|
|
localStorage.setItem("pos_order_id", "51211");
|
|
step.value = 2;
|
|
order_id.value = 51211;
|
|
customer_id.value = 12345679;
|
|
customer_name.value = "Existing Customer";
|
|
order_items.value = [{ id: 1 }];
|
|
reference.value = "STALE-REF";
|
|
order_notes.value = "STALE-NOTES";
|
|
order_po.value = "STALE-PO";
|
|
order_safety_seal.value = "STALE-SEAL";
|
|
reg_1.value = "AB12345";
|
|
reg_2.value = "CD12345";
|
|
reg_3.value = "EF12345";
|
|
invoiceCollectionId.value = 7001;
|
|
completed_at.value = "2026-01-01T10:00:00.000Z";
|
|
scans.value = [{ id: 1 }];
|
|
scan_data.value = [{ id: 2 }];
|
|
|
|
expect(clearActivePosOrderContext({ clearStep: true })).toBe(true);
|
|
|
|
expect(step.value).toBe(1);
|
|
expect(order_id.value).toBeNull();
|
|
expect(customer_id.value).toBe("");
|
|
expect(customer_name.value).toBe("");
|
|
expect(order_items.value).toEqual([]);
|
|
expect(reference.value).toBe("");
|
|
expect(order_notes.value).toBe("");
|
|
expect(order_po.value).toBe("");
|
|
expect(order_safety_seal.value).toBe("");
|
|
expect(reg_1.value).toBe("");
|
|
expect(reg_2.value).toBe("");
|
|
expect(reg_3.value).toBe("");
|
|
expect(invoiceCollectionId.value).toBeNull();
|
|
expect(completed_at.value).toBeNull();
|
|
expect(scans.value).toEqual([]);
|
|
expect(scan_data.value).toEqual([]);
|
|
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
|
|
beforeEach(() => {
|
|
clearStoredPosOrderId();
|
|
order_id.value = null;
|
|
department_id.value = "";
|
|
SessionUser.objects.orders.get.single.mockReset();
|
|
SessionUser.objects.orders.functions.get_department_id.mockReset();
|
|
SessionUser.objects.orders.set.department_id.mockReset();
|
|
});
|
|
|
|
it("clears invalid stored order ids before validation", async () => {
|
|
localStorage.setItem("pos_order_id", "invalid");
|
|
|
|
await expect(restoreStoredPosOrderId()).resolves.toBeNull();
|
|
|
|
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
|
expect(SessionUser.objects.orders.get.single).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores a valid stored order id when order context matches", async () => {
|
|
localStorage.setItem("pos_order_id", "51211");
|
|
SessionUser.objects.orders.get.single.mockResolvedValue({
|
|
id: 51211,
|
|
customer_id: 12345679,
|
|
department_id: 2,
|
|
completed_at: null,
|
|
});
|
|
|
|
await expect(
|
|
restoreStoredPosOrderId({
|
|
validateOrder: true,
|
|
customerId: 12345679,
|
|
departmentId: 2,
|
|
})
|
|
).resolves.toBe(51211);
|
|
|
|
expect(order_id.value).toBe(51211);
|
|
expect(localStorage.getItem("pos_order_id")).toBe("51211");
|
|
expect(SessionUser.objects.orders.get.single).toHaveBeenCalledWith(51211);
|
|
});
|
|
|
|
it("clears stored order id when customer or department does not match", async () => {
|
|
localStorage.setItem("pos_order_id", "51211");
|
|
order_id.value = 51211;
|
|
SessionUser.objects.orders.get.single.mockResolvedValue({
|
|
id: 51211,
|
|
customer_id: 999,
|
|
department_id: 88,
|
|
completed_at: null,
|
|
});
|
|
|
|
await expect(
|
|
restoreStoredPosOrderId({
|
|
validateOrder: true,
|
|
customerId: 12345679,
|
|
departmentId: 2,
|
|
})
|
|
).resolves.toBeNull();
|
|
|
|
expect(order_id.value).toBeNull();
|
|
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
|
});
|
|
|
|
it("clears stored order id when order lookup fails", async () => {
|
|
localStorage.setItem("pos_order_id", "51211");
|
|
order_id.value = 51211;
|
|
SessionUser.objects.orders.get.single.mockRejectedValue(new Error("Order not found"));
|
|
|
|
await expect(
|
|
restoreStoredPosOrderId({
|
|
validateOrder: true,
|
|
})
|
|
).resolves.toBeNull();
|
|
|
|
expect(order_id.value).toBeNull();
|
|
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
|
});
|
|
|
|
it("rejects a stored current order from a different department instead of moving it", async () => {
|
|
localStorage.setItem("pos_order_id", "51211");
|
|
department_id.value = 2;
|
|
SessionUser.objects.orders.get.single.mockResolvedValue({
|
|
id: 51211,
|
|
customer_id: 12345679,
|
|
department_id: 88,
|
|
completed_at: null,
|
|
});
|
|
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(88);
|
|
|
|
await expect(
|
|
restoreStoredPosOrderId({
|
|
validateOrder: true,
|
|
customerId: 12345679,
|
|
departmentId: 2,
|
|
syncDepartment: true,
|
|
})
|
|
).resolves.toBeNull();
|
|
|
|
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
|
|
expect(order_id.value).toBeNull();
|
|
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("POSDepartmentProcess.createOrder department sync", () => {
|
|
beforeEach(() => {
|
|
window.history.replaceState({}, "", "/admin/1/modules/pos");
|
|
order_id.value = null;
|
|
department_id.value = "";
|
|
axios.post.mockReset();
|
|
SessionUser.functions.getDepartmentIdFromUrl.mockReset();
|
|
SessionUser.functions.getDepartmentIdFromUrl.mockReturnValue(undefined);
|
|
SessionUser.objects.orders.functions.get_department_id.mockReset();
|
|
SessionUser.objects.orders.set.department_id.mockReset();
|
|
localStorage.setItem("token", "test-token");
|
|
});
|
|
|
|
it("rejects an existing current order from a different department instead of updating it", async () => {
|
|
order_id.value = 9201;
|
|
department_id.value = 7;
|
|
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(3);
|
|
|
|
await expect(createOrder({ isMobile: true })).resolves.toBe(false);
|
|
|
|
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
|
|
expect(axios.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reuses an existing current order when it already belongs to the selected department", async () => {
|
|
order_id.value = 9201;
|
|
department_id.value = 7;
|
|
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(7);
|
|
|
|
await expect(createOrder({ isMobile: true })).resolves.toBe(true);
|
|
|
|
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
|
|
expect(axios.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates a new order in the selected department", async () => {
|
|
department_id.value = 7;
|
|
axios.post.mockResolvedValue({
|
|
data: {
|
|
success: true,
|
|
data: {
|
|
id: 9202,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(createOrder({ isMobile: true })).resolves.toBe(true);
|
|
|
|
expect(axios.post).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
department_id: 7,
|
|
is_handheld: true,
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
expect(order_id.value).toBe(9202);
|
|
expect(department_id.value).toBe(7);
|
|
});
|
|
|
|
it("falls back to the route department when the selected department ref is not numeric", async () => {
|
|
department_id.value = "Demo";
|
|
axios.post.mockResolvedValue({
|
|
data: {
|
|
success: true,
|
|
data: {
|
|
id: 9203,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(createOrder({ isMobile: false })).resolves.toBe(true);
|
|
|
|
expect(axios.post).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
department_id: 1,
|
|
is_handheld: false,
|
|
}),
|
|
expect.any(Object)
|
|
);
|
|
expect(order_id.value).toBe(9203);
|
|
expect(department_id.value).toBe(1);
|
|
});
|
|
});
|