Files
pleno-vue/tests/unit/invoicing-period-queue-refresh.behavior.spec.js
T

843 lines
24 KiB
JavaScript

// @vitest-environment jsdom
import { computed, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
requestMock,
startDateRef,
endDateRef,
routeState,
periodRefreshSignalRef,
finishPeriodRefreshMock,
routerReplaceMock,
availableViewNamesRef,
currentViewRef,
sharedVariablesRef,
} = vi.hoisted(() => {
const { reactive, ref } = require("vue");
return {
requestMock: vi.fn(),
startDateRef: ref(new Date("2026-04-01T00:00:00.000Z")),
endDateRef: ref(new Date("2026-04-30T23:59:59.000Z")),
routeState: reactive({
query: {
activeTab: "period",
},
}),
periodRefreshSignalRef: ref({
sequence: 0,
customerNumbers: [],
invoiceCollectionIds: [],
}),
finishPeriodRefreshMock: vi.fn(),
routerReplaceMock: vi.fn(() => Promise.resolve()),
availableViewNamesRef: ref({}),
currentViewRef: ref("all"),
sharedVariablesRef: ref({}),
};
});
vi.mock("vue-router", () => ({
useRoute: () => routeState,
useRouter: () => ({
replace: routerReplaceMock,
}),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: requestMock,
objects: {
global: {
language: {
all: "All",
invoice_per_order: "Invoice per order",
fixed_price_arrangements: "Fixed pricing",
only_tank_cleaning: "Tank cleaning",
special_arrangements: "Special arrangements",
possible_duplicates: "Possible duplicates",
},
},
vehicles: {
columns: {
wash_subscription: {
label: "Vehicle subscriptions",
},
},
},
},
},
}));
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue",
() => ({
dates: {
variables: {
start: startDateRef,
end: endDateRef,
},
computed: {
isEntireMonth: computed(() => true),
formattedStartDate: computed(() => startDateRef.value.toISOString().split("T")[0]),
formattedEndDate: computed(() => endDateRef.value.toISOString().split("T")[0]),
},
},
})
);
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue",
() => ({
view: {
variables: {
sharedVariables: sharedVariablesRef,
currentView: currentViewRef,
availableViewNames: availableViewNamesRef,
},
},
})
);
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue",
() => ({
invoiceQueue: {
periodRefreshSignal: periodRefreshSignalRef,
finishPeriodRefresh: finishPeriodRefreshMock,
},
})
);
import Right from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/Right.vue";
import {
periodPaging,
resetPeriodPagingState,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const flushAll = async () => {
await nextTick();
await Promise.resolve();
await nextTick();
};
let mountedWrappers = [];
const mountRight = () => {
const wrapper = mount(Right, {
global: {
stubs: {
WhiteBox: { template: "<div><slot /></div>" },
ColorIndicator: { props: ["label"], template: "<div>{{ label?.text }}</div>" },
},
},
});
mountedWrappers.push(wrapper);
return wrapper;
};
const emptyPeriodResponse = () => ({
data: {
data: {
types: {
all: [],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
},
});
const selfWashResponse = (rows = [], total = rows.length) => ({
data: {
data: rows,
meta: {
pagination: {
total,
},
},
},
});
const mockPeriodResponses = (...responses) => {
let periodResponseIndex = 0;
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
const response = responses[periodResponseIndex] ?? responses[responses.length - 1] ?? emptyPeriodResponse();
periodResponseIndex += 1;
return Promise.resolve(response);
}
return Promise.resolve(selfWashResponse());
});
};
const periodCalls = () => requestMock.mock.calls.filter(([url]) => url === "/superuser/invoicing/period");
const selfWashCalls = () => requestMock.mock.calls.filter(([url]) => url === "/modules/xlvask/services/usage/orders");
describe("Invoicing period queue-driven refresh", () => {
beforeEach(() => {
requestMock.mockReset();
mockPeriodResponses(emptyPeriodResponse());
routeState.query.activeTab = "period";
delete routeState.query.periodView;
delete routeState.query.periodSearch;
delete routeState.query.periodPage;
delete routeState.query.periodLimit;
periodRefreshSignalRef.value = {
sequence: 0,
customerNumbers: [],
invoiceCollectionIds: [],
};
finishPeriodRefreshMock.mockReset();
routerReplaceMock.mockClear();
resetPeriodPagingState();
sharedVariablesRef.value = {};
availableViewNamesRef.value = {};
currentViewRef.value = "all";
});
afterEach(() => {
mountedWrappers.forEach((wrapper) => wrapper.unmount());
mountedWrappers = [];
});
it("refreshes the current paginated page when queue activity reaches a terminal state", async () => {
mockPeriodResponses(
{
data: {
data: {
types: {
all: [
{ customer_number: 1001, customer_name: "Keep Customer", transactions: [], expanded: true },
{ customer_number: 1002, customer_name: "Old Customer", transactions: [], expanded: true },
{ customer_number: 1003, customer_name: "After Customer", transactions: [] },
],
invoice_per_order: [],
fixed_pricing: [{ customer_number: 1001, customer_name: "Keep Customer", transactions: [] }],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
},
},
{
data: {
data: {
types: {
all: [
{ customer_number: 1001, customer_name: "Keep Customer", transactions: [], expanded: true },
{ customer_number: 1002, customer_name: "Updated Customer", transactions: [], expanded: true },
{ customer_number: 1003, customer_name: "After Customer", transactions: [] },
],
invoice_per_order: [],
fixed_pricing: [{ customer_number: 1001, customer_name: "Keep Customer", transactions: [] }],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
},
}
);
mountRight();
await flushAll();
expect(periodCalls()).toHaveLength(1);
periodRefreshSignalRef.value = {
sequence: 1,
customerNumbers: [1002],
invoiceCollectionIds: [3002],
};
await flushAll();
expect(periodCalls()).toHaveLength(2);
expect(periodCalls().at(-1)).toEqual([
"/superuser/invoicing/period",
"GET",
{
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
periodView: "all",
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
},
]);
expect(sharedVariablesRef.value.types.all.map((customer) => customer.customer_number)).toEqual([1001, 1002, 1003]);
expect(sharedVariablesRef.value.types.all[0].expanded).toBe(true);
expect(sharedVariablesRef.value.types.all[1].customer_name).toBe("Updated Customer");
expect(sharedVariablesRef.value.types.all[1].expanded).toBe(true);
expect(sharedVariablesRef.value.types.fixed_pricing.map((customer) => customer.customer_number)).toEqual([1001]);
expect(finishPeriodRefreshMock).toHaveBeenCalledWith([1002], [3002]);
});
it("requests the active period page and renders selector counts from backend type_counts", async () => {
mockPeriodResponses({
data: {
data: {
types: {
all: [{ customer_number: 2001, customer_name: "Visible Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
type_counts: {
all: {
requires_action: 2,
draft: 1,
manual_flags: 0,
automatic_flags: 0,
completed: 37,
total: 40,
},
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 40,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
const wrapper = mountRight();
await flushAll();
expect(periodCalls()).toContainEqual([
"/superuser/invoicing/period",
"GET",
{
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
periodView: "all",
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
},
]);
expect(wrapper.get("[data-testid='invoicing-period-view-selector-all']").text()).toContain("All (37/40)");
});
it("preloads the next active page and keeps current plus previous month cache warm", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-12T10:00:00.000Z"));
try {
mockPeriodResponses({
data: {
data: {
types: {
all: [{ customer_number: 2501, customer_name: "Warm Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 250,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
mountRight();
await flushAll();
expect(periodCalls()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(100);
await flushAll();
const requestedParams = periodCalls().map(([, , params]) => params);
expect(requestedParams).toContainEqual({
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
periodView: "all",
page: 2,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
periodWarm: 1,
});
expect(requestedParams).toContainEqual({
dateFrom: "2026-05-01",
dateTo: "2026-05-31",
periodView: "all",
page: 1,
limit: 100,
search: "",
includeRequiresAction: 1,
includeBooked: 1,
periodWarm: 1,
});
} finally {
vi.useRealTimers();
}
});
it("renders cached period data while a fresh request is still pending", async () => {
mockPeriodResponses({
data: {
data: {
types: {
all: [{ customer_number: 3001, customer_name: "Cached Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
type_counts: {
all: {
requires_action: 0,
draft: 0,
manual_flags: 0,
automatic_flags: 0,
completed: 1,
total: 1,
},
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
const firstWrapper = mountRight();
await flushAll();
firstWrapper.unmount();
mountedWrappers = mountedWrappers.filter((wrapper) => wrapper !== firstWrapper);
sharedVariablesRef.value = {};
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
return new Promise(() => {});
}
return Promise.resolve(selfWashResponse());
});
const secondWrapper = mountRight();
await flushAll();
expect(periodCalls()).toHaveLength(2);
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("Cached Customer");
expect(secondWrapper.get("[data-testid='invoicing-period-view-selector-all']").text()).toContain("All (1/1)");
});
it("keeps the current customer list visible while an uncached reload is pending", async () => {
let resolveReload;
requestMock.mockImplementation((url, _method, params = {}) => {
if (url !== "/superuser/invoicing/period") {
return Promise.resolve(selfWashResponse());
}
if (params.search === "Beta") {
return new Promise((resolve) => {
resolveReload = resolve;
});
}
return Promise.resolve({
data: {
data: {
types: {
all: [{ customer_number: 3101, customer_name: "Current Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
});
mountRight();
await flushAll();
periodPaging.search = "Beta";
await flushAll();
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("Current Customer");
expect(periodPaging.isRefreshing).toBe(true);
resolveReload({
data: {
data: {
types: {
all: [{ customer_number: 3102, customer_name: "Reloaded Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "Beta",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
await flushAll();
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("Reloaded Customer");
expect(periodPaging.isRefreshing).toBe(false);
});
it("keeps the current right-menu view rendered until the switched view is ready", async () => {
let resolveViewSwitch;
requestMock.mockImplementation((url, _method, params = {}) => {
if (url !== "/superuser/invoicing/period") {
return Promise.resolve(selfWashResponse());
}
if (params.periodView === "fixed_pricing") {
return new Promise((resolve) => {
resolveViewSwitch = resolve;
});
}
return Promise.resolve({
data: {
data: {
types: {
all: [{ customer_number: 3201, customer_name: "All Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
});
const wrapper = mountRight();
await flushAll();
await wrapper.get("[data-testid='invoicing-period-view-selector-fixed_pricing']").trigger("click");
await flushAll();
expect(currentViewRef.value).toBe("all");
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("All Customer");
expect(periodPaging.isRefreshing).toBe(true);
resolveViewSwitch({
data: {
data: {
types: {
all: [],
invoice_per_order: [],
fixed_pricing: [{ customer_number: 3202, customer_name: "Fixed Customer", transactions: [] }],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
await flushAll();
expect(currentViewRef.value).toBe("fixed_pricing");
expect(sharedVariablesRef.value.types.fixed_pricing[0].customer_name).toBe("Fixed Customer");
expect(periodPaging.isRefreshing).toBe(false);
});
it("keeps the current right-menu view when a background view switch fails", async () => {
let rejectViewSwitch;
requestMock.mockImplementation((url, _method, params = {}) => {
if (url !== "/superuser/invoicing/period") {
return Promise.resolve(selfWashResponse());
}
if (params.periodView === "fixed_pricing") {
return new Promise((_resolve, reject) => {
rejectViewSwitch = reject;
});
}
return Promise.resolve({
data: {
data: {
types: {
all: [{ customer_number: 3211, customer_name: "Stable Customer", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
});
const wrapper = mountRight();
await flushAll();
await wrapper.get("[data-testid='invoicing-period-view-selector-fixed_pricing']").trigger("click");
await flushAll();
rejectViewSwitch(new Error("Reload failed"));
await flushAll();
expect(currentViewRef.value).toBe("all");
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("Stable Customer");
expect(periodPaging.isRefreshing).toBe(false);
});
it("reuses cached Selvvask selector counts without repeating the broad usage-order request", async () => {
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
return Promise.resolve(emptyPeriodResponse());
}
return Promise.resolve(
selfWashResponse(
[
{ id: 8101, linked_order_id: 7001 },
{ id: 8102, linked_order_id: null },
],
2
)
);
});
const firstWrapper = mountRight();
await flushAll();
expect(firstWrapper.get("[data-testid='invoicing-period-view-selector-self_wash']").text()).toContain(
"Selvvask (1/2)"
);
expect(selfWashCalls()).toHaveLength(1);
firstWrapper.unmount();
mountedWrappers = mountedWrappers.filter((wrapper) => wrapper !== firstWrapper);
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
return new Promise(() => {});
}
return Promise.resolve(selfWashResponse([], 0));
});
const secondWrapper = mountRight();
await flushAll();
expect(secondWrapper.get("[data-testid='invoicing-period-view-selector-self_wash']").text()).toContain(
"Selvvask (1/2)"
);
expect(selfWashCalls()).toHaveLength(1);
});
it("ignores stale period responses that resolve after a newer search request", async () => {
const deferred = [];
requestMock.mockImplementation((url) => {
if (url !== "/superuser/invoicing/period") {
return Promise.resolve(selfWashResponse());
}
return new Promise((resolve) => {
deferred.push(resolve);
});
});
mountRight();
await flushAll();
const callsBeforeSearch = periodCalls().length;
periodPaging.search = "new";
await flushAll();
expect(periodCalls().length).toBeGreaterThan(callsBeforeSearch);
const latestDeferredIndex = deferred.length - 1;
deferred[latestDeferredIndex]({
data: {
data: {
types: {
all: [{ customer_number: 4002, customer_name: "New Result", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
meta: {
pagination: {
page: 1,
per_page: 100,
total: 1,
search: "new",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
});
await flushAll();
for (let index = 0; index < latestDeferredIndex; index += 1) {
deferred[index]({
data: {
data: {
types: {
all: [{ customer_number: 4001, customer_name: "Old Result", transactions: [] }],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
},
});
}
await flushAll();
expect(sharedVariablesRef.value.types.all[0].customer_name).toBe("New Result");
});
it("refreshes when the Period tab becomes active after being in another tab", async () => {
routeState.query.activeTab = "overview";
mountRight();
await flushAll();
const initialCallCount = requestMock.mock.calls.length;
expect(initialCallCount).toBeGreaterThanOrEqual(1);
routeState.query.activeTab = "period";
await flushAll();
expect(requestMock.mock.calls.length).toBeGreaterThan(initialCallCount);
});
});