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

800 lines
26 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 {
sharedVariablesRef,
currentViewRef,
startDateRef,
endDateRef,
queueRef,
inProgressRef,
loadingCustomerNumbersRef,
} = vi.hoisted(() => {
const { ref } = require("vue");
return {
sharedVariablesRef: ref({ types: { all: [] } }),
currentViewRef: ref("all"),
startDateRef: ref(new Date("2026-04-01T00:00:00.000Z")),
endDateRef: ref(new Date("2026-04-30T23:59:59.000Z")),
queueRef: ref([]),
inProgressRef: ref([]),
loadingCustomerNumbersRef: ref([]),
};
});
vi.mock("@/services/economicTransferQueue.js", () => ({
ECONOMIC_QUEUE_STATUS: {
QUEUED: "QUEUED",
PROCESSING: "PROCESSING",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
processing: "Processing",
queued: "Queued",
all_booked: "All booked",
none: "None",
invoice_now: "Invoice now",
},
},
orders: {
meta: {
labels: {
single: "order",
multiple: "orders",
},
},
get: {
multiple: vi.fn().mockResolvedValue([]),
},
},
collectedOrderInvoices: {
functions: {
createVehicleSubscriptionInvoice: vi.fn(),
add_fixed_pricing: vi.fn(),
add_vehicle_subscriptions: vi.fn(),
},
},
vehicles: {
columns: {
wash_subscription: {
label: "Subscription",
},
},
},
},
functions: {
currency: {
toLocal: (value) => String(value),
},
},
},
}));
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue",
() => ({
view: {
variables: {
currentView: currentViewRef,
sharedVariables: sharedVariablesRef,
},
computed: {
componentName: computed(() => currentViewRef.value),
},
functions: {
filterExcluded: (transactions = []) => transactions.filter((transaction) => !transaction?.excluded),
getCustomerViewTotalNetAmount: (customer) => {
const transactions = Array.isArray(customer?.transactions) ? customer.transactions : [];
return transactions.reduce((sum, transaction) => sum + Number(transaction?.amount ?? 0), 0);
},
},
},
})
);
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue",
() => ({
dates: {
variables: {
start: startDateRef,
end: endDateRef,
},
computed: {
formattedStartDate: require("vue").computed(() => startDateRef.value.toISOString().split("T")[0]),
formattedEndDate: require("vue").computed(() => endDateRef.value.toISOString().split("T")[0]),
},
},
})
);
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue",
() => ({
invoiceQueue: {
addInvoiceCollectionsToQueue: vi.fn(),
processInvoiceCollectionQueue: vi.fn(),
invoiceCollectionQueue: queueRef,
invoiceCollectionQueueInProgress: inProgressRef,
invoiceCollectionQueueFailed: require("vue").ref([]),
invoiceCollectionQueueSuccess: require("vue").ref([]),
markPeriodRefreshLoading: vi.fn((customerNumbers = []) => {
loadingCustomerNumbersRef.value = Array.from(
new Set([...loadingCustomerNumbersRef.value, ...customerNumbers.filter(Boolean)])
);
}),
finishPeriodRefresh: vi.fn((customerNumbers = []) => {
loadingCustomerNumbersRef.value = loadingCustomerNumbersRef.value.filter(
(customerNumber) => !customerNumbers.includes(customerNumber)
);
}),
isPeriodCustomerRefreshLoading: vi.fn((customerNumber) =>
loadingCustomerNumbersRef.value.includes(Number(customerNumber))
),
},
})
);
import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
import {
periodPaging,
resetPeriodPagingState,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const mountView = () =>
mount(InvoicingBillingPeriodViewAll, {
global: {
stubs: {
"b-tabs": {
template: "<div data-testid='b-tabs-stub'><slot /></div>",
},
"b-tab-item": {
template:
"<div class='b-tab-item-stub' v-bind='$attrs'><div class='b-tab-item-header'><slot name='header' /></div><slot /></div>",
},
InvoicingBillingPeriodStatistics: { template: "<div />" },
InvoicingBillingPeriodFilters: { template: "<div />" },
PaginationNavigation: {
props: ["currentPage", "totalPages", "setPage", "loadFunction"],
template: `
<div data-testid="pagination-navigation-stub">
<button data-testid="invoicing-period-page-prev" @click="setPage(currentPage - 1); loadFunction()">Prev</button>
<button data-testid="invoicing-period-page-next" @click="setPage(currentPage + 1); loadFunction()">Next</button>
</div>
`,
},
WhiteBox: { template: "<div><slot /></div>" },
ColorIndicator: {
props: ["icon_class", "color_class"],
template:
"<div class='color-indicator-stub' :data-icon='icon_class' :data-color='color_class'><slot /></div>",
},
ActionSettingsWheelButton: { template: "<div><slot name='actions' /></div>" },
InvoicingPeriodFlagList: {
emits: ["statusChanged"],
props: ["flags"],
methods: {
markStatus(flag) {
this.$emit("statusChanged", { ...flag, status: "ignored" });
},
},
template: `
<div data-testid='invoice-period-flags-stub'>
{{ flags.length }}
<button
v-for="flag in flags"
:key="flag.id || flag.fingerprint"
:data-testid="'invoice-period-flag-status-' + (flag.id || flag.fingerprint)"
@click="markStatus(flag)"
></button>
</div>
`,
},
InvoiceOrdersPagination: {
props: ["setCustomerFilter", "showOnlyWithIds", "groupInvoiceCollection"],
template:
'<div class="invoice-orders-pagination-stub" :data-customer="setCustomerFilter" :data-order-ids="(showOnlyWithIds || []).join(\',\')" :data-group-invoice-collection="groupInvoiceCollection ? \'true\' : \'false\'" />',
},
SmallCustomerActivityChart: { template: "<div />" },
InvoicingBillingPeriodCustomerAttributes: { template: "<div />" },
InvoicingBillingPeriodInvoiceProgressBar: { template: "<div />" },
},
},
});
describe("Invoicing period queue state", () => {
beforeEach(() => {
currentViewRef.value = "all";
queueRef.value = [];
inProgressRef.value = [];
loadingCustomerNumbersRef.value = [];
SessionUser.objects.orders.get.multiple.mockReset();
SessionUser.objects.orders.get.multiple.mockResolvedValue([]);
invoiceQueue.addInvoiceCollectionsToQueue.mockReset();
invoiceQueue.processInvoiceCollectionQueue.mockReset();
invoiceQueue.markPeriodRefreshLoading.mockClear();
invoiceQueue.finishPeriodRefresh.mockClear();
invoiceQueue.isPeriodCustomerRefreshLoading.mockClear();
resetPeriodPagingState();
sharedVariablesRef.value = {
types: {
all: [
{
id: 1,
customer_number: 1001,
customer_name: "Queued Customer",
requires_action: false,
queue: {
has_active_job: true,
statuses: ["QUEUED"],
invoice_collection_ids: [14578],
is_action_blocked: true,
},
transactions: [
{
id: 5001,
amount: 100,
booked: false,
excluded: false,
queue_status: "QUEUED",
queue_job_id: 9,
invoice_collection_id: 14578,
date: "2026-04-10T10:00:00.000Z",
},
],
},
{
id: 2,
customer_number: 1002,
customer_name: "Mixed Customer",
requires_action: true,
queue: {
has_active_job: true,
statuses: ["PROCESSING"],
invoice_collection_ids: [2001],
is_action_blocked: false,
},
transactions: [
{
id: 5002,
amount: 120,
booked: false,
excluded: false,
queue_status: "PROCESSING",
queue_job_id: 10,
invoice_collection_id: 2001,
date: "2026-04-11T10:00:00.000Z",
},
{
id: 5003,
amount: 80,
booked: false,
excluded: false,
queue_status: null,
queue_job_id: null,
invoice_collection_id: null,
date: "2026-04-12T10:00:00.000Z",
},
],
},
],
},
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a disabled queued CTA when the backend marks the customer as action-blocked", async () => {
const wrapper = mountView();
await nextTick();
const queuedButton = wrapper.get("[data-testid='invoicing-period-customer-queue-1001']");
expect(queuedButton.text()).toContain("Queued");
expect(queuedButton.attributes("disabled")).toBeDefined();
expect(wrapper.find("[data-testid='invoicing-period-customer-invoice-1001']").exists()).toBe(false);
});
it("debounces period search input and resets pagination to the first page", async () => {
vi.useFakeTimers();
try {
periodPaging.page = 3;
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-search-input']").setValue("Solaris");
expect(periodPaging.search).toBe("");
expect(periodPaging.page).toBe(3);
await vi.advanceTimersByTimeAsync(299);
expect(periodPaging.search).toBe("");
await vi.advanceTimersByTimeAsync(1);
await nextTick();
expect(periodPaging.search).toBe("Solaris");
expect(periodPaging.page).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("offers large period page sizes and all rows", async () => {
periodPaging.page = 4;
const wrapper = mountView();
await nextTick();
const select = wrapper.get("[data-testid='invoicing-period-limit-select']");
const optionValues = Array.from(select.element.options).map((option) => option.value);
const optionLabels = Array.from(select.element.options).map((option) => option.textContent.trim());
expect(select.element.value).toBe("100");
expect(optionValues).toEqual(["10", "25", "50", "100", "200", "500", "all"]);
expect(optionLabels.at(-1)).toBe("All");
await select.setValue("all");
expect(periodPaging.limit).toBe("all");
expect(periodPaging.page).toBe(1);
await select.setValue("500");
expect(periodPaging.limit).toBe(500);
});
it("stores table filters in the Filters tab for the standard view", async () => {
const wrapper = mountView();
await nextTick();
const filtersTab = wrapper.get("[data-testid='invoicing-period-filters-tab']");
expect(filtersTab.find("[data-testid='invoicing-period-table-filters']").exists()).toBe(true);
expect(filtersTab.find("[data-testid='invoicing-period-limit-select']").exists()).toBe(true);
});
it("keeps flag tab counts stable when only the page size changes", async () => {
periodPaging.typeCounts = {
all: {
total: 793,
manual_flags: 2,
automatic_flags: 0,
},
};
const wrapper = mountView();
await nextTick();
const getFlagTabCounts = () =>
wrapper
.findAll("[data-testid='invoicing-period-flag-tabs'] .period-flag-tab-header .tag")
.map((tag) => Number.parseInt(tag.text(), 10));
expect(getFlagTabCounts()).toEqual([793, 2, 0, 791]);
const select = wrapper.get("[data-testid='invoicing-period-limit-select']");
await select.setValue("10");
await nextTick();
expect(periodPaging.limit).toBe(10);
expect(getFlagTabCounts()).toEqual([793, 2, 0, 791]);
});
it("keeps only one customer unfolded at a time", async () => {
const wrapper = mountView();
await nextTick();
await wrapper
.get("[data-testid='invoicing-period-customer-1001'] .columns.is-vcentered.is-clickable")
.trigger("click");
await nextTick();
expect(sharedVariablesRef.value.types.all[0].expanded).toBe(true);
expect(sharedVariablesRef.value.types.all[1].expanded).toBe(false);
expect(wrapper.find(".invoice-orders-pagination-stub[data-customer='1001']").exists()).toBe(true);
expect(wrapper.find(".invoice-orders-pagination-stub[data-customer='1002']").exists()).toBe(false);
await wrapper
.get("[data-testid='invoicing-period-customer-1002'] .columns.is-vcentered.is-clickable")
.trigger("click");
await nextTick();
expect(sharedVariablesRef.value.types.all[0].expanded).toBe(false);
expect(sharedVariablesRef.value.types.all[1].expanded).toBe(true);
expect(wrapper.find(".invoice-orders-pagination-stub[data-customer='1001']").exists()).toBe(false);
expect(wrapper.find(".invoice-orders-pagination-stub[data-customer='1002']").exists()).toBe(true);
});
it("keeps the invoice action visible for mixed queued and still-unqueued customers", async () => {
const wrapper = mountView();
await nextTick();
expect(wrapper.find("[data-testid='invoicing-period-customer-queue-1002']").exists()).toBe(false);
expect(wrapper.get("[data-testid='invoicing-period-customer-invoice-1002']").text()).toContain("Invoice now");
});
it("uses a flag icon and renders flag rows when the period response contains active flags", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 6,
customer_number: 1006,
customer_name: "Flagged Customer",
requires_action: false,
status_indicator: "flag_red",
flag_counts: {
manual: 1,
automatic: 1,
total: 2,
},
flags: [
{
id: 991,
source: "manual",
status: "active",
target_type: "customer",
target_id: 1006,
customer_number: 1006,
reason: "Manual check",
},
{
id: "auto:abc",
source: "automatic",
status: "active",
target_type: "customer",
target_id: 1006,
customer_number: 1006,
message: "Automatic check",
},
],
transactions: [
{
id: 7101,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 4101,
date: "2026-04-13T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1006']");
expect(customer.get(".color-indicator-stub").attributes("data-icon")).toBe("fas fa-flag");
expect(customer.get(".color-indicator-stub").attributes("data-color")).toBe("has-text-danger");
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("2");
});
it("keeps collected invoice flags visible even when linked transactions are excluded", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 7,
customer_number: 1007,
customer_name: "Collection Flag Customer",
requires_action: false,
status_indicator: "flag_red",
flag_counts: {
manual: 1,
automatic: 0,
total: 1,
},
flags: [
{
id: 992,
source: "manual",
status: "active",
target_type: "collected_order_invoice",
target_id: 5101,
customer_number: 1007,
reason: "Invoice collection needs review",
},
],
transactions: [
{
id: 7201,
amount: 95,
booked: false,
excluded: true,
invoice_collection_id: 5101,
date: "2026-04-14T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1007']");
expect(customer.get(".color-indicator-stub").attributes("data-icon")).toBe("fas fa-flag");
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("1");
});
it("removes the customer flag icon after the last active flag is marked inactive", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 6,
customer_number: 1006,
customer_name: "Flagged Customer",
requires_action: true,
status_indicator: "flag_yellow",
flag_counts: {
manual: 0,
automatic: 1,
total: 1,
},
flags: [
{
id: "auto:abc",
source: "automatic",
status: "active",
target_type: "customer",
target_id: 1006,
customer_number: 1006,
message: "Automatic check",
},
],
transactions: [
{
id: 7101,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 4101,
date: "2026-04-13T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
const customer = wrapper.get("[data-testid='invoicing-period-customer-1006']");
expect(customer.get(".color-indicator-stub").attributes("data-icon")).toBe("fas fa-flag");
await customer.get("[data-testid='invoice-period-flag-status-auto:abc']").trigger("click");
await nextTick();
expect(customer.get(".color-indicator-stub").attributes("data-icon")).toBe("fas fa-circle");
expect(customer.get(".color-indicator-stub").attributes("data-color")).toBe("has-text-danger");
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("0");
expect(sharedVariablesRef.value.types.all[0].flag_counts).toEqual({
manual: 0,
automatic: 0,
total: 0,
});
expect(periodPaging.reloadSequence).toBe(1);
});
it("queues period invoice collections without opening the invoice collection page", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
sharedVariablesRef.value = {
types: {
all: [
{
id: 3,
customer_number: 1003,
customer_name: "Ready Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 6001,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-13T10:00:00.000Z",
},
{
id: 6002,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-14T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1003']").trigger("click");
await nextTick();
expect(SessionUser.objects.orders.get.multiple).not.toHaveBeenCalled();
expect(invoiceQueue.addInvoiceCollectionsToQueue).toHaveBeenCalledWith([3001], {
customerNumber: 1003,
});
expect(invoiceQueue.processInvoiceCollectionQueue).toHaveBeenCalledTimes(1);
expect(windowOpenSpy).not.toHaveBeenCalled();
});
it("applies invoice-now loading only to the affected customer", async () => {
sharedVariablesRef.value = {
types: {
all: [
{
id: 4,
customer_number: 1004,
customer_name: "Loading Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 7001,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 4001,
date: "2026-04-13T10:00:00.000Z",
},
],
},
{
id: 5,
customer_number: 1005,
customer_name: "Independent Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 7002,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 4002,
date: "2026-04-14T10:00:00.000Z",
},
],
},
],
},
};
loadingCustomerNumbersRef.value = [1004];
const wrapper = mountView();
await nextTick();
const loadingButton = wrapper.get("[data-testid='invoicing-period-customer-invoice-1004']");
const independentButton = wrapper.get("[data-testid='invoicing-period-customer-invoice-1005']");
expect(loadingButton.classes()).toContain("is-loading");
expect(loadingButton.attributes("disabled")).toBeDefined();
expect(independentButton.classes()).not.toContain("is-loading");
expect(independentButton.attributes("disabled")).toBeUndefined();
});
it("groups possible duplicates by vehicle plate and date across customers", async () => {
currentViewRef.value = "possible_duplicates";
sharedVariablesRef.value = {
types: {
possible_duplicates: [
{
id: 11,
customer_number: 2101,
customer_name: "Pleno Vognmandsforretning",
requires_action: true,
transactions: [
{
id: 8101,
amount: 100,
booked: false,
excluded: false,
reg_1: "EC 21233",
date: "2026-04-10T10:00:00.000Z",
},
],
},
{
id: 12,
customer_number: 2102,
customer_name: "Estland Alle ApS",
requires_action: true,
transactions: [
{
id: 8102,
amount: 125,
booked: false,
excluded: false,
reg_1: "EC21233",
created_at: "2026-04-10 12:15:00",
},
],
},
{
id: 13,
customer_number: 2103,
customer_name: "Single Wash ApS",
requires_action: true,
transactions: [
{
id: 8103,
amount: 90,
booked: false,
excluded: false,
reg_1: "SINGLE1",
created_at: "2026-04-10 12:15:00",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
expect(wrapper.find("[data-testid='invoicing-period-customer-2101']").exists()).toBe(false);
const group = wrapper.get("[data-testid='invoicing-period-duplicate-group-EC21233-2026-04-10']");
expect(wrapper.find("[data-testid='invoicing-period-duplicate-group-SINGLE1-2026-04-10']").exists()).toBe(false);
expect(group.text()).toContain("2 kunder");
expect(group.text()).toContain("2 vaskelog");
expect(group.text()).not.toContain("EC21233 - 10/04/2026");
expect(group.text()).toContain("Pleno Vognmandsforretning #2101");
expect(group.text()).toContain("Estland Alle ApS #2102");
const duplicateTags = group.find(".duplicate-group-tags");
expect(duplicateTags.exists()).toBe(true);
expect(duplicateTags.findAll(".duplicate-group-chip")).toHaveLength(2);
await group.find(".duplicate-group-header").trigger("click");
await nextTick();
const expanded = wrapper.get("[data-testid='invoicing-period-duplicate-group-expanded-EC21233-2026-04-10']");
expect(expanded.text()).toContain("Sammenligning");
expect(expanded.text()).toContain("EC21233");
expect(expanded.text()).toContain("10/04/2026");
expect(expanded.findAll(".duplicate-comparison-row")).toHaveLength(2);
const pagination = expanded.get(".invoice-orders-pagination-stub");
expect(pagination.attributes("data-customer")).toBeUndefined();
expect(pagination.attributes("data-order-ids")).toBe("8101,8102");
expect(pagination.attributes("data-group-invoice-collection")).toBe("false");
});
});