275 lines
7.5 KiB
JavaScript
275 lines
7.5 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { flushPromises, mount } from "@vue/test-utils";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
|
import { createTestI18n } from "./helpers/mountWithApp.js";
|
|
|
|
// Track mock state in a hoisted holder so the vi.mock factory below can stay
|
|
// free of top-level variables (vitest hoists vi.mock above imports, so the
|
|
// factory must not reference any binding that has not been hoisted).
|
|
const requestHolder = vi.hoisted(() => ({
|
|
responses: [],
|
|
calls: 0,
|
|
}));
|
|
|
|
const showPopperMock = vi.hoisted(() => vi.fn());
|
|
const removePopperIfOpenMock = vi.hoisted(() => vi.fn());
|
|
const popperBoxMock = vi.hoisted(() => vi.fn((title, body) => ({ title, body })));
|
|
const swalFireMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ isConfirmed: false })));
|
|
|
|
vi.mock("sweetalert2", () => ({
|
|
default: {
|
|
fire: swalFireMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/displays/PopperDefault.vue", () => ({
|
|
showPopper: showPopperMock,
|
|
removePopperIfOpen: removePopperIfOpenMock,
|
|
popperBox: popperBoxMock,
|
|
}));
|
|
|
|
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|
SessionUser: {
|
|
request: () => {
|
|
requestHolder.calls += 1;
|
|
const next = requestHolder.responses.shift();
|
|
return Promise.resolve(next ?? { data: { data: [] } });
|
|
},
|
|
functions: {
|
|
currency: {
|
|
toLocal: (value) => String(value ?? ""),
|
|
},
|
|
redirectTo: {
|
|
department: vi.fn(),
|
|
},
|
|
},
|
|
objects: {
|
|
vehicles: {
|
|
columns: {
|
|
wash_subscription: {
|
|
label: "Wash subscription",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
const i18n = createTestI18n({
|
|
en: {
|
|
global: {
|
|
cancel: "Cancel",
|
|
no_data: "No data",
|
|
quantity: "Quantity",
|
|
},
|
|
tables: {
|
|
products: {
|
|
name: "Product",
|
|
price: "Price",
|
|
},
|
|
},
|
|
objects: {
|
|
columns: {
|
|
notes: "Notes",
|
|
reference: "Reference",
|
|
},
|
|
},
|
|
invoice_period: {
|
|
flags: {
|
|
status: {
|
|
resolved: "Resolved",
|
|
ignored: "Ignored",
|
|
false_positive: "False positive",
|
|
reason_placeholder: "Optional reason",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const buildWrapper = (props = {}) =>
|
|
mount(OrderContentTable, {
|
|
props: {
|
|
orderId: 42,
|
|
...props,
|
|
},
|
|
global: {
|
|
plugins: [i18n],
|
|
},
|
|
});
|
|
|
|
describe("OrderContentTable addon ordering", () => {
|
|
beforeEach(() => {
|
|
requestHolder.responses = [];
|
|
requestHolder.calls = 0;
|
|
showPopperMock.mockReset();
|
|
removePopperIfOpenMock.mockReset();
|
|
popperBoxMock.mockReset();
|
|
swalFireMock.mockReset();
|
|
});
|
|
|
|
it("renders primary items before their addons even when the API returns them out of order", async () => {
|
|
// Simulate the legacy MySQL response: addons appear before the primary
|
|
// item they belong to. After the fix, the component must render the
|
|
// primary first.
|
|
const apiResponse = [
|
|
{
|
|
id: 102,
|
|
order_id: 42,
|
|
product_id: 9,
|
|
related_item_id: 101,
|
|
price: 50,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 9, name: "Addon A" },
|
|
},
|
|
{
|
|
id: 103,
|
|
order_id: 42,
|
|
product_id: 10,
|
|
related_item_id: 101,
|
|
price: 30,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 10, name: "Addon B" },
|
|
},
|
|
{
|
|
id: 101,
|
|
order_id: 42,
|
|
product_id: 1,
|
|
related_item_id: 0,
|
|
price: 200,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 1, name: "Primary wash" },
|
|
},
|
|
];
|
|
requestHolder.responses.push({ data: { data: apiResponse } });
|
|
requestHolder.responses.push({ data: { data: apiResponse } });
|
|
|
|
const wrapper = buildWrapper();
|
|
await flushPromises();
|
|
|
|
const renderedRows = wrapper.findAll("tbody tr");
|
|
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
|
|
|
const primaryIndex = productNames.findIndex((text) => text.includes("Primary wash"));
|
|
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
|
|
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
|
|
|
|
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
|
expect(addonAIndex).toBeGreaterThanOrEqual(0);
|
|
expect(addonBIndex).toBeGreaterThanOrEqual(0);
|
|
expect(primaryIndex).toBeLessThan(addonAIndex);
|
|
expect(primaryIndex).toBeLessThan(addonBIndex);
|
|
});
|
|
|
|
it("preserves insertion order for addons that share the same parent", async () => {
|
|
const apiResponse = [
|
|
{
|
|
id: 101,
|
|
order_id: 42,
|
|
product_id: 1,
|
|
related_item_id: 0,
|
|
price: 200,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 1, name: "Primary wash" },
|
|
},
|
|
{
|
|
id: 103,
|
|
order_id: 42,
|
|
product_id: 10,
|
|
related_item_id: 101,
|
|
price: 30,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 10, name: "Addon B" },
|
|
},
|
|
{
|
|
id: 102,
|
|
order_id: 42,
|
|
product_id: 9,
|
|
related_item_id: 101,
|
|
price: 50,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 9, name: "Addon A" },
|
|
},
|
|
];
|
|
// OrderContentTable mounts twice on initial render (immediate watcher plus
|
|
// a top-level call); queue two identical responses.
|
|
requestHolder.responses.push({ data: { data: apiResponse } });
|
|
requestHolder.responses.push({ data: { data: apiResponse } });
|
|
|
|
const wrapper = buildWrapper();
|
|
await flushPromises();
|
|
|
|
const renderedRows = wrapper.findAll("tbody tr");
|
|
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
|
|
|
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
|
|
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
|
|
|
|
expect(addonAIndex).toBeLessThan(addonBIndex);
|
|
});
|
|
|
|
it("sorts localOrderItems defensively when useLocalOrderItems is true", async () => {
|
|
const localItems = [
|
|
{
|
|
id: 201,
|
|
order_id: 7,
|
|
product_id: 9,
|
|
related_item_id: 200,
|
|
price: 50,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 9, name: "Local addon" },
|
|
},
|
|
{
|
|
id: 200,
|
|
order_id: 7,
|
|
product_id: 1,
|
|
related_item_id: 0,
|
|
price: 200,
|
|
quantity: 1,
|
|
include_in_invoice: true,
|
|
notes: "",
|
|
reference: "",
|
|
product: { id: 1, name: "Local primary" },
|
|
},
|
|
];
|
|
|
|
const wrapper = buildWrapper({
|
|
orderId: 7,
|
|
useLocalOrderItems: true,
|
|
localOrderItems: localItems,
|
|
});
|
|
await flushPromises();
|
|
|
|
const renderedRows = wrapper.findAll("tbody tr");
|
|
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
|
|
|
const primaryIndex = productNames.findIndex((text) => text.includes("Local primary"));
|
|
const addonIndex = productNames.findIndex((text) => text.includes("Local addon"));
|
|
|
|
expect(primaryIndex).toBeLessThan(addonIndex);
|
|
expect(requestHolder.calls).toBe(0);
|
|
});
|
|
});
|