Files
pleno-vue/tests/unit/orders-table.spec.js
T

869 lines
26 KiB
JavaScript

// @vitest-environment jsdom
import { defineComponent, h, nextTick, ref } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import { setUseLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
const getSingleMock = vi.hoisted(() => vi.fn());
const bulkActionPreviewMock = vi.hoisted(() => vi.fn());
const bulkActionApplyMock = vi.hoisted(() => vi.fn());
const sessionState = vi.hoisted(() => ({
canAccessSuperUser: false,
}));
const routeState = vi.hoisted(() => ({
path: "/admin/12/modules/pos/orders",
fullPath: "/admin/12/modules/pos/orders",
name: "posorders",
params: {
departmentId: "12",
},
}));
const invoiceQueueState = vi.hoisted(() => {
const { ref } = require("vue");
return {
inProgress: ref([]),
failed: ref([]),
success: ref([]),
queued: ref([]),
log: ref([]),
};
});
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key) => key,
locale: {
value: "en",
},
}),
createI18n: () => ({
global: {
t: (key) => key,
},
install: () => {},
}),
}));
vi.mock("vue-router", () => ({
useRoute: () => routeState,
}));
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(() => Promise.resolve()),
close: vi.fn(),
},
}));
vi.mock("@/components/shop/POSDepartmentProcess.vue", () => ({
downloadAttachment: vi.fn(),
}));
vi.mock("@/components/pagination/departmentTabs.vue", () => ({
departments: ref([{ id: 12, name: "Demo" }]),
getDepartments: vi.fn(),
isLoading: ref(false),
getDepartmentName: (departmentId) => (Number(departmentId) === 12 ? "Demo" : `Department ${departmentId}`),
}));
vi.mock("@/components/displays/PopperDefault.vue", () => ({
showPopper: vi.fn(),
removePopperIfOpen: vi.fn(),
popperBox: vi.fn(),
}));
vi.mock("@/components/pagination/paginatedList.vue", () => {
const orderBy = ref("id");
const orderDirection = ref("asc");
return {
orderBy,
orderDirection,
setOrder: vi.fn(),
loadList: vi.fn(),
usePaginatedListInstance: () => ({
orderBy,
orderDirection,
setOrder: vi.fn(),
loadList: vi.fn(),
}),
};
});
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue",
() => ({
invoiceQueue: {
invoiceCollectionQueueInProgress: invoiceQueueState.inProgress,
invoiceCollectionQueueFailed: invoiceQueueState.failed,
invoiceCollectionQueueSuccess: invoiceQueueState.success,
invoiceCollectionQueue: invoiceQueueState.queued,
collectionQueueLog: invoiceQueueState.log,
addInvoiceCollectionsToQueue: vi.fn(),
processInvoiceCollectionQueue: vi.fn(),
retryInvoiceCollection: vi.fn(),
},
})
);
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
canAccessDepartment: vi.fn(() => false),
canAccessAdmin: vi.fn(() => false),
canAccessSuperUser: vi.fn(() => sessionState.canAccessSuperUser),
hasPermission: vi.fn(() => false),
hasAttribute: vi.fn(() => false),
objects: {
orders: {
columns: {
id: { label: "ID" },
customer_id: { label: "Customer" },
cashier_id: { label: "Cashier" },
department_id: { label: "Department" },
reference: { label: "Reference" },
notes: { label: "Notes" },
po: { label: "PO" },
created_at: { label: "Created" },
},
meta: {
labels: {
single: "Order",
multiple: "Orders",
entries: "Orders",
},
},
showEditObjectFieldForm: vi.fn(() => Promise.resolve()),
},
vehicles: {
meta: {
labels: {
multiple: "Vehicles",
},
},
},
departments: {
meta: {
labels: {
single: "department",
},
},
},
collectedOrderInvoices: {
get: {
single: getSingleMock,
},
functions: {
bulk_action_preview: bulkActionPreviewMock,
bulk_action_apply: bulkActionApplyMock,
},
},
global: {
language: {
showing: "Showing",
showing_of_separator: "of",
unselect: "Unselect",
select: "Select",
all: "All",
invoice: "Invoice",
collection: "Collection",
yes: "Yes",
no: "No",
handheld_pending_order: "Pending handheld",
confirmation_needed: "Confirmation needed",
},
system_user_ids: [],
},
},
functions: {
ucFirst: (value) => {
const normalized = String(value || "");
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
},
currency: {
toLocal: (value) => String(value ?? ""),
},
text: {
truncate: (text, length = 25) => (text.length > length ? `${text.substring(0, length)}...` : text),
},
date: {
timeAgo: () => "just now",
},
parseErrorMessage: (error) => error?.message || String(error),
},
},
}));
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
const ViewportResponsiveWrapperStub = {
template: `
<div class="viewport-wrapper-stub">
<slot name="desktop"></slot>
</div>
`,
};
const EditableTableColumnStub = defineComponent({
name: "EditableTableColumn",
props: {
object: {
type: Object,
required: true,
},
column: {
type: String,
default: null,
},
parseFunction: {
type: Function,
default: null,
},
virtualColumn: {
type: Boolean,
default: false,
},
componentWrapper: {
type: String,
default: null,
},
},
setup(props) {
return () => {
const tagName = props.componentWrapper || "td";
const rawValue = props.virtualColumn ? props.object : props.object?.[props.column];
const content = props.parseFunction ? props.parseFunction(rawValue) : rawValue;
return h(tagName, { class: "editable-table-column-stub" }, String(content ?? ""));
};
},
});
const ColorIndicatorStub = defineComponent({
name: "ColorIndicator",
props: {
label: {
type: Object,
default: () => ({}),
},
icon_class: {
type: String,
default: "fas fa-circle",
},
color_class: {
type: String,
default: "has-text-grey",
},
},
setup(props) {
return () =>
h(
"span",
{
class: "color-indicator-stub",
"data-label": String(props.label?.text ?? ""),
"data-icon": props.icon_class,
"data-color-class": props.color_class,
},
String(props.label?.text ?? "")
);
},
});
const ActionSettingsWheelButtonStub = {
template: '<div class="action-settings-wheel-button-stub"><slot name="actions"></slot></div>',
};
const InvoiceCollectionSelectionActionWheelStub = defineComponent({
name: "InvoiceCollectionSelectionActionWheel",
props: {
selectedInvoiceCollectionIds: {
type: Array,
default: () => [],
},
totalInvoiceCollectionCount: {
type: Number,
default: 0,
},
allSelected: {
type: Boolean,
default: false,
},
allExpanded: {
type: Boolean,
default: false,
},
invoiceQueueBusy: {
type: Boolean,
default: false,
},
},
emits: ["invoiceSelected", "bulkAction", "toggleSelectAll", "toggleExpandAll"],
template: `
<div
class="invoice-collection-selection-action-wheel-stub"
:data-selected-count="selectedInvoiceCollectionIds.length"
:data-total-count="totalInvoiceCollectionCount"
:data-all-selected="String(allSelected)"
:data-all-expanded="String(allExpanded)"
:data-queue-busy="String(invoiceQueueBusy)"
>
<button data-testid="selection-wheel-invoice-selected" @click="$emit('invoiceSelected')"></button>
<button data-testid="selection-wheel-clean-rules" @click="$emit('bulkAction', '${INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES}')"></button>
<button data-testid="selection-wheel-select-all" @click="$emit('toggleSelectAll')"></button>
<button data-testid="selection-wheel-expand-all" @click="$emit('toggleExpandAll')"></button>
</div>
`,
});
const BCheckboxStub = defineComponent({
name: "BCheckbox",
props: {
modelValue: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
template: `
<label :class="$attrs.class" :data-testid="$attrs['data-testid']">
<input
type="checkbox"
:aria-label="$attrs['aria-label']"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', $event.target.checked)"
/>
</label>
`,
});
const ActionSettingsWheelItemStub = {
template: '<div class="action-settings-wheel-item-stub"></div>',
};
const OrderContentTableStub = {
template: '<div class="order-content-table-stub"></div>',
};
const PosDepartmentStepMobileAttachmentStub = {
template: '<div class="pos-mobile-attachment-stub"></div>',
};
const OrderAttachmentsActionButtonStub = {
template: '<div class="order-attachments-action-button-stub"></div>',
};
const AssignDraftOrderCustomerModalStub = {
template: '<div class="assign-draft-order-customer-modal-stub"></div>',
};
const WhiteBoxCardStub = {
template:
'<div class="white-box-card-stub"><slot name="header"></slot><slot></slot><slot name="footer"></slot></div>',
};
const InvoiceMultipleCollectionsModalStub = {
template: '<div class="invoice-multiple-collections-modal-stub"></div>',
};
const InvoicingBillingPeriodInvoiceProgressBarStub = {
template: '<div class="invoice-progress-bar-stub"></div>',
};
const createOrder = (overrides = {}) => ({
id: 1,
customer_id: 12345,
department_id: 12,
reference: "",
notes: "",
po: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
created_at: "2026-04-13 11:00:00",
total_net_amount: 100,
invoice_collection_id: null,
invoice_collection: null,
attachments: [],
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
pending_handheld: false,
completed_at: null,
customer_name: "Demo Customer",
cashier_id: 7,
cashier_name: "Demo Cashier",
...overrides,
});
const mountOrdersTable = (props = {}) => {
return mount(OrdersTable, {
props: {
orders: [],
isCustomerView: true,
invoiceView: false,
allowSelectMultiple: false,
groupInvoiceCollection: false,
showOnlyWithIds: [],
autoExpandAll: false,
excludedOrderIds: [],
...props,
},
global: {
stubs: {
ViewportResponsiveWrapper: ViewportResponsiveWrapperStub,
EditableTableColumn: EditableTableColumnStub,
ColorIndicator: ColorIndicatorStub,
ActionSettingsWheelButton: ActionSettingsWheelButtonStub,
InvoiceCollectionSelectionActionWheel: InvoiceCollectionSelectionActionWheelStub,
BCheckbox: BCheckboxStub,
ActionSettingsWheelItem: ActionSettingsWheelItemStub,
OrderContentTable: OrderContentTableStub,
PosDepartmentStepMobileAttachment: PosDepartmentStepMobileAttachmentStub,
OrderAttachmentsActionButton: OrderAttachmentsActionButtonStub,
AssignDraftOrderCustomerModal: AssignDraftOrderCustomerModalStub,
WhiteBoxCard: WhiteBoxCardStub,
InvoiceMultipleCollectionsModal: InvoiceMultipleCollectionsModalStub,
InvoicingBillingPeriodInvoiceProgressBar: InvoicingBillingPeriodInvoiceProgressBarStub,
},
},
});
};
describe("OrdersTable", () => {
beforeEach(() => {
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
sessionState.canAccessSuperUser = false;
routeState.path = "/admin/12/modules/pos/orders";
routeState.fullPath = "/admin/12/modules/pos/orders";
routeState.name = "posorders";
routeState.params = {
departmentId: "12",
};
invoiceQueueState.inProgress.value = [];
invoiceQueueState.failed.value = [];
invoiceQueueState.success.value = [];
invoiceQueueState.queued.value = [];
invoiceQueueState.log.value = [];
setUseLargeTableHeaders(false);
getSingleMock.mockReset();
bulkActionPreviewMock.mockReset();
bulkActionPreviewMock.mockResolvedValue({
data: {
data: {
preview_id: "preview-1",
confirmation_phrase: "Confirm",
summary: {
changed_count: 0,
},
blockers: [],
},
},
});
bulkActionApplyMock.mockReset();
});
afterEach(() => {
setUseLargeTableHeaders(false);
vi.restoreAllMocks();
});
it("renders customer orders with sparse text fields without crashing or preloading invoice collections", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const wrapper = mountOrdersTable({
orders: [
createOrder({
id: 10,
reference: undefined,
notes: null,
po: undefined,
invoice_collection_id: undefined,
}),
],
});
await flushPromises();
expect(wrapper.text()).toContain("10");
expect(getSingleMock).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain(
"Cannot read properties of undefined"
);
});
it("preloads only valid invoice collection ids and keeps rendering when a lookup fails", async () => {
sessionState.canAccessSuperUser = true;
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
getSingleMock.mockImplementation(async (invoiceCollectionId) => {
if (invoiceCollectionId === 14) {
return {
id: 14,
orders: [{ id: 1001 }, { id: 1002 }, { id: 3000 }],
};
}
throw new Error("Forbidden");
});
const wrapper = mountOrdersTable({
groupInvoiceCollection: true,
orders: [
createOrder({
id: 20,
invoice_collection_id: 14,
invoice_collection: { id: 14, closed_at: null, booked_invoice_id: null },
}),
createOrder({
id: 21,
invoice_collection_id: "14",
invoice_collection: { id: 14, closed_at: null, booked_invoice_id: null },
}),
createOrder({
id: 22,
invoice_collection_id: undefined,
}),
createOrder({
id: 23,
invoice_collection_id: "",
}),
createOrder({
id: 24,
invoice_collection_id: -1,
}),
createOrder({
id: 25,
invoice_collection_id: 21,
invoice_collection: { id: 21, closed_at: null, booked_invoice_id: null },
}),
],
excludedOrderIds: [3000],
});
await flushPromises();
await flushPromises();
const requestedIds = getSingleMock.mock.calls
.map(([invoiceCollectionId]) => invoiceCollectionId)
.sort((left, right) => left - right);
expect(requestedIds).toEqual([14, 21]);
expect(wrapper.text()).toContain("Faktura samling ID: 14");
expect(wrapper.text()).toContain("Faktura samling ID: 21");
expect(consoleErrorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain(
"Unhandled error during execution of component update"
);
});
it("renders the draft assignment action when explicitly enabled", async () => {
const wrapper = mountOrdersTable({
showDraftAssignmentActions: true,
orders: [
createOrder({
id: 56625,
}),
],
});
await flushPromises();
expect(wrapper.find('[data-testid="draft-order-assign-customer-button-56625"]').exists()).toBe(true);
expect(wrapper.text()).toContain("admin.pos.drafts_assignment.button");
});
it("does not mount the legacy invoice progress toast on superuser invoice routes", async () => {
routeState.path = "/superuser/invoices";
routeState.fullPath = "/superuser/invoices?activeTab=period";
routeState.name = "superuser-invoices";
routeState.params = {};
invoiceQueueState.failed.value = [3001];
invoiceQueueState.success.value = [3002];
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [
createOrder({
id: 56626,
invoice_collection_id: 3001,
}),
],
});
await flushPromises();
expect(wrapper.find(".invoice-progress-bar-stub").exists()).toBe(false);
});
it("keeps the legacy invoice progress toast available outside superuser invoice routes", async () => {
invoiceQueueState.queued.value = [3003];
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [
createOrder({
id: 56627,
invoice_collection_id: 3003,
}),
],
});
await flushPromises();
expect(wrapper.find(".invoice-progress-bar-stub").exists()).toBe(true);
});
it("renders invoice-period order flags below the matching order row", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [
createOrder({
id: 56628,
}),
createOrder({
id: 56629,
}),
],
invoicePeriodFlags: [
{
id: "manual-order-flag",
source: "manual",
target_type: "order",
target_id: 56628,
order_id: 56628,
reason: "Review this order.",
message: "Review this order.",
},
{
id: "item-flag",
source: "automatic",
target_type: "order_item_field",
target_id: 7701,
order_id: 56628,
order_item_id: 7701,
message: "Item flag belongs on the item line.",
},
],
});
await flushPromises();
expect(wrapper.get('[data-testid="pos-order-flags-row-56628"]').text()).toContain("Review this order.");
expect(wrapper.find('[data-testid="pos-order-flags-row-56629"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="pos-order-flags-row-56628"]').text()).not.toContain(
"Item flag belongs on the item line."
);
});
it("uses a flag icon for orders containing invoice-period flags", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [
createOrder({
id: 56630,
pending_handheld: true,
}),
createOrder({
id: 56631,
}),
createOrder({
id: 56632,
}),
],
invoicePeriodFlags: [
{
id: "manual-order-flag",
source: "manual",
target_type: "order",
target_id: 56630,
order_id: 56630,
reason: "Review this order.",
message: "Review this order.",
},
{
id: "automatic-item-flag",
source: "automatic",
target_type: "order_item_field",
target_id: 7701,
order_id: 56631,
order_item_id: 7701,
message: "Item flag belongs to this order.",
},
],
});
await flushPromises();
const getIndicatorForOrder = (orderId) => wrapper.get(`.color-indicator-stub[data-label="${orderId}"]`);
expect(getIndicatorForOrder(56630).attributes("data-icon")).toBe("fas fa-flag");
expect(getIndicatorForOrder(56630).attributes("data-color-class")).toBe("has-text-danger");
expect(getIndicatorForOrder(56631).attributes("data-icon")).toBe("fas fa-flag");
expect(getIndicatorForOrder(56631).attributes("data-color-class")).toBe("has-text-warning");
expect(getIndicatorForOrder(56632).attributes("data-icon")).toBe("fas fa-circle");
});
it("routes invoice collection selection controls through the action wheel", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
allowSelectMultiple: true,
autoExpandAll: false,
orders: [
createOrder({
id: 56633,
invoice_collection_id: 3001,
invoice_collection: { id: 3001, closed_at: null, booked_invoice_id: null },
}),
createOrder({
id: 56634,
invoice_collection_id: 3001,
invoice_collection: { id: 3001, closed_at: null, booked_invoice_id: null },
}),
createOrder({
id: 56635,
invoice_collection_id: 3002,
invoice_collection: { id: 3002, closed_at: null, booked_invoice_id: null },
}),
],
});
await flushPromises();
const wheel = () => wrapper.get(".invoice-collection-selection-action-wheel-stub");
const firstCollectionSelector = () =>
wrapper.get('[data-testid="pos-order-invoice-collection-selector-56633"] input');
const secondSameCollectionSelector = () =>
wrapper.get('[data-testid="pos-order-invoice-collection-selector-56634"] input');
expect(wheel().attributes("data-total-count")).toBe("2");
expect(wheel().attributes("data-selected-count")).toBe("0");
expect(wheel().attributes("data-all-selected")).toBe("false");
expect(wheel().attributes("data-all-expanded")).toBe("false");
expect(wrapper.findAll("button").some((button) => /select|unselect|vælg|fravælg/i.test(button.text()))).toBe(false);
expect(wrapper.get('[data-testid="pos-order-invoice-collection-selector-56633"]').classes()).toContain(
"pos-order-invoice-collection-selector"
);
await firstCollectionSelector().setValue(true);
await flushPromises();
expect(wheel().attributes("data-selected-count")).toBe("1");
expect(firstCollectionSelector().element.checked).toBe(true);
expect(secondSameCollectionSelector().element.checked).toBe(true);
await secondSameCollectionSelector().setValue(false);
await flushPromises();
expect(wheel().attributes("data-selected-count")).toBe("0");
expect(firstCollectionSelector().element.checked).toBe(false);
await wrapper.get('[data-testid="selection-wheel-select-all"]').trigger("click");
await flushPromises();
expect(wheel().attributes("data-selected-count")).toBe("2");
expect(wheel().attributes("data-all-selected")).toBe("true");
await wrapper.get('[data-testid="selection-wheel-expand-all"]').trigger("click");
await flushPromises();
expect(wheel().attributes("data-all-expanded")).toBe("true");
await wrapper.get('[data-testid="selection-wheel-clean-rules"]').trigger("click");
await flushPromises();
expect(bulkActionPreviewMock).toHaveBeenCalledWith(
INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES,
[3001, 3002],
{},
"en"
);
});
it("uses compact table headers by default and restores large headers from the user setting", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [createOrder()],
});
await flushPromises();
expect(wrapper.find("th.pos-orders-table-header--compact").exists()).toBe(true);
expect(wrapper.find("th.pos-orders-table-header--vehicles.pos-orders-table-header--compact").exists()).toBe(true);
expect(wrapper.find("th .pleno-table-header-content").exists()).toBe(true);
setUseLargeTableHeaders(true);
await nextTick();
expect(wrapper.find("th.pos-orders-table-header--compact").exists()).toBe(false);
expect(wrapper.find("th.pos-orders-table-header--vehicles").exists()).toBe(true);
});
it("renders stacked desktop registration numbers in the compact vehicle column style", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
orders: [
createOrder({
reg_1: "AB12345",
reg_2: "CD67890",
reg_3: "EF24680",
}),
],
});
await flushPromises();
const vehicleCell = wrapper.get(".pos-order-vehicle-cell");
const registrationLines = vehicleCell.findAll(".pos-order-vehicle-registration-line");
expect(registrationLines).toHaveLength(2);
expect(registrationLines.map((line) => line.text())).toEqual(["AB12345", "CD67890/EF24680"]);
});
it("centers the desktop ID status indicator group in the ID cell", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
allowSelectMultiple: true,
isCustomerView: false,
orders: [createOrder({ id: 54518 })],
});
await flushPromises();
const idCell = wrapper.get(".pos-orders-table-id-cell");
expect(idCell.classes()).toContain("is-narrow");
expect(idCell.text()).toContain("54518");
expect(idCell.find(".pos-orders-table-id-cell-content").exists()).toBe(true);
expect(idCell.find(".color-indicator-stub").exists()).toBe(true);
});
it("uses row-level alignment classes for the desktop orders table row", async () => {
const wrapper = mountOrdersTable({
invoiceView: true,
allowSelectMultiple: true,
isCustomerView: false,
orders: [createOrder({ id: 54518 })],
});
await flushPromises();
const row = wrapper.get(".pos-orders-table-row");
expect(wrapper.find(".pos-orders-table-selector-header").exists()).toBe(true);
expect(wrapper.find(".pos-orders-table-collapse-header").exists()).toBe(true);
expect(row.findAll(".pos-orders-table-leading-control-cell")).toHaveLength(2);
expect(row.find(".pos-orders-table-id-cell").exists()).toBe(true);
expect(row.find(".pos-orders-table-customer-cell").exists()).toBe(true);
expect(row.find(".pos-order-vehicle-cell").exists()).toBe(true);
expect(row.find(".pos-orders-table-date-cell").text()).toBe("2026-04-13 11:00:00");
expect(row.find(".pos-orders-table-amount-cell").text()).toBe("100");
expect(row.find(".pos-orders-table-actions-cell").exists()).toBe(true);
expect(row.findAll(".pos-orders-table-compact-text-cell")).toHaveLength(7);
});
});