// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach } from "vitest";
const mocks = vi.hoisted(() => {
const order = {
id: 9001,
invoice_collection_id: 3001,
department_id: 75,
created_at: "2026-06-18",
total_net_amount: 240,
reference: "REF-9001",
notes: "",
po: "",
reg_1: "AB12345",
};
const orderItems = [
{
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
},
];
return {
order,
orderItems,
economicDetails: vi.fn(async () => ({
data: {
data: {
collected_invoice_id: 3001,
economic: {
draft_id: 88,
booked_id: 99,
},
internal: {
normalized: {
totals: {
net_total: 240,
line_count: 1,
},
},
},
draft: {
exists: false,
normalized: null,
},
booked: {
exists: true,
normalized: {
totals: {
net_total: 240,
line_count: 1,
},
},
},
warnings: [],
},
},
})),
economicPdf: vi.fn(async () => ({ data: { data: { url: "https://example.test/invoice.pdf" } } })),
request: vi.fn(async (url, method) => {
if (url === "/order/items" && method === "GET") {
return {
data: {
data: orderItems,
},
};
}
return { data: { data: [] } };
}),
};
});
vi.mock("buefy", () => ({
BCheckbox: {
name: "BCheckbox",
props: {
modelValue: Boolean,
indeterminate: Boolean,
disabled: Boolean,
},
emits: ["update:modelValue"],
template: `
`,
},
}));
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(async () => ({ isConfirmed: false })),
},
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: "da-DK" },
}),
}));
vi.mock("@/components/displays/buttons/EditableTableColumn.vue", () => ({
default: {
name: "EditableTableColumn",
props: ["object", "column", "parseFunction", "editFunction", "loadList", "cellTestId"],
template: ``,
},
}));
vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({
default: {
name: "ActionSettingsWheelButton",
props: ["menuSections", "order_id", "department_id", "invoice_collection_id", "customer_number"],
template: `
`,
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
functions: {
currency: {
toLocal: (value) => `${Number(value || 0)} DKK`,
},
parseErrorMessage: (error) => error?.message || String(error),
},
hasPermission: () => true,
canAccessAdmin: () => true,
canAccessSuperUser: () => true,
canAccessDepartment: () => true,
objects: {
orders: {
columns: {
reference: { label: "Reference" },
notes: { label: "Noter" },
po: { label: "PO" },
reg_1: { label: "Reg 1" },
reg_2: { label: "Reg 2" },
reg_3: { label: "Reg 3" },
include_in_invoice: { label: "Faktura" },
total_net_amount: { label: "Total" },
},
get: {
single: vi.fn(async () => mocks.order),
},
functions: {
fetchAttachments: vi.fn(async () => []),
fetchAttachmentContent: vi.fn(async () => new Blob(["pdf"], { type: "application/pdf" })),
downloadAttachment: vi.fn(async () => "https://example.test/attachment.pdf"),
removeAttachment: vi.fn(),
resendWashCertificate: vi.fn(),
mark_as_completed: vi.fn(),
},
set: {
include_in_invoice: vi.fn(),
invoice_collection_id: vi.fn(),
booking_id: vi.fn(),
wash_id: vi.fn(),
},
delete: {
single: vi.fn(),
},
showEditObjectFieldForm: vi.fn(),
},
collectedOrderInvoices: {
columns: {
name: { label: "Navn" },
notes: { label: "Noter" },
po_number: { label: "PO" },
customer_number: { label: "Kundenr." },
external_id: { label: "Eksternt ID" },
closed_at: { label: "Lukket" },
total_net_amount: { label: "Total" },
},
functions: {
economic: {
v2: {
details: mocks.economicDetails,
},
pdf: mocks.economicPdf,
},
bulk_action_preview: vi.fn(),
bulk_action_apply: vi.fn(),
period_tree_action_preview: vi.fn(),
period_tree_action_apply: vi.fn(),
},
showEditObjectFieldForm: vi.fn(),
},
order_bookings: {
get: {
single: vi.fn(),
},
functions: {
resendBookingConfirmation: vi.fn(),
resendBookingCompletionConfirmation: vi.fn(),
},
delete: {
single: vi.fn(),
},
},
},
},
}));
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue",
() => ({
invoiceQueue: {
addInvoiceCollectionsToQueue: vi.fn(),
processInvoiceCollectionQueue: vi.fn(),
},
default: {},
})
);
vi.mock("@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js", () => ({
INVOICE_COLLECTION_BULK_ACTIONS: {
QUEUE_ECONOMIC: "queue_economic",
CLEAN_CUSTOMER_RULES: "remove_customer_rule_violations",
MERGE: "merge_collections",
SPLIT_BY_MONTH: "split_by_month",
RESET_HIDDEN_PRICES: "reset_hidden_item_prices",
},
}));
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
const snapshotResponse = (revision = "rev-1", actions = {}) => ({
data: {
data: {
complete: true,
snapshot_revision: revision,
customer_number: 2001,
date_from: "2026-06-01",
date_to: "2026-06-30",
capabilities: { object_tree_v2: true, actions },
customer: { customer_number: 2001, customer_name: "ACME" },
collections: [
{
id: 3001,
complete_order_count: 2,
complete_total_net_amount: 300,
period_order_count: 1,
period_total_net_amount: 240,
in_selected_period: true,
orders: [
{
...mocks.order,
in_selected_period: true,
items: mocks.orderItems,
attachments: [],
bookings: [],
xlvask: [],
},
{
id: 9002,
invoice_collection_id: 3001,
in_selected_period: false,
total_net_amount: 60,
items: [],
attachments: [],
bookings: [],
xlvask: [],
},
],
},
],
uncollected_orders: [],
agreements: [],
payments: [],
economic_invoices: [],
},
},
});
const mountTree = (overrides = {}) =>
mount(InvoicingPeriodObjectTree, {
props: {
customer: {
customer_number: 2001,
customer_name: "ACME",
draft: {
has_valid_draft: true,
invoice_collection_ids: [3001],
},
},
transactions: [
{
...mocks.order,
booked: true,
},
],
excludedOrderIds: [],
dates: {
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
},
invoicePeriodFlags: [],
...overrides,
},
global: {
stubs: {
InvoicingPeriodFlagBadge: {
name: "InvoicingPeriodFlagBadge",
props: ["flags"],
template: ``,
},
},
},
});
const expandNode = async (wrapper, key) => {
const node = wrapper.find(`[data-node-key="${key}"]`);
expect(node.exists()).toBe(true);
await node.find(".b-tree-node-toggle").trigger("click");
await flushPromises();
};
describe("InvoicingPeriodObjectTree", () => {
beforeEach(() => {
vi.clearAllMocks();
Swal.fire.mockReset().mockResolvedValue({ isConfirmed: false });
mocks.request.mockImplementation(async (url, method) => {
if (url === "/order/items" && method === "GET") {
return {
data: {
data: mocks.orderItems,
},
};
}
return { data: { data: [] } };
});
mocks.orderItems.splice(0, mocks.orderItems.length, {
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
});
delete mocks.order.reg_2;
delete mocks.order.reg_3;
delete mocks.order.attachments;
Object.defineProperty(window, "open", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:attachment-preview"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
});
it("auto-expands order items when an order node is expanded", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(mocks.request).toHaveBeenCalledWith("/order/items", "GET", { order_id: 9001 });
expect(wrapper.find('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
expect(wrapper.text()).toContain("Premium wash");
});
it("shows order-item quantity and price once through the standard editable fields", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
const orderItem = wrapper.get('[data-testid="invoice-period-tree-node-order_item:501"]');
expect(orderItem.find(".invoice-period-tree-node__subtitle").exists()).toBe(false);
const quantity = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"]');
const price = wrapper.get('[data-testid="invoice-period-tree-field-order_item:501-price"]');
expect(quantity.text()).toContain("Antal");
expect(quantity.text()).toContain("2");
expect(price.text()).toContain("Pris");
expect(price.text()).toContain("120 DKK");
expect(quantity.find(".editable-table-column-stub").exists()).toBe(true);
expect(price.find(".editable-table-column-stub").exists()).toBe(true);
});
it("preserves untouched snapshot item values during an inline edit", async () => {
mocks.orderItems[0].reference = "KEEP-REFERENCE";
mocks.orderItems[0].notes = "KEEP-NOTES";
mocks.request.mockImplementation(async (url, method) => {
if (url === "/superuser/invoicing/period/tree" && method === "GET") {
return snapshotResponse("rev-edit");
}
return { data: { data: [] } };
});
Swal.fire.mockResolvedValueOnce({ isConfirmed: true, value: "135" });
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledWith("/order/items", "PUT", {
id: 501,
price: 135,
notes: "KEEP-NOTES",
reference: "KEEP-REFERENCE",
quantity: 2,
});
expect(mocks.request.mock.calls.filter(([url]) => url === "/superuser/invoicing/period/tree")).toHaveLength(2);
});
it("rejects partial snapshot items before a full inline update can erase text fields", async () => {
delete mocks.orderItems[0].notes;
mocks.request.mockImplementation(async (url, method) => {
if (url === "/superuser/invoicing/period/tree" && method === "GET") {
return snapshotResponse("rev-partial-item");
}
return { data: { data: [] } };
});
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
expect(mocks.request.mock.calls.some(([url, method]) => url === "/order/items" && method === "PUT")).toBe(false);
expect(Swal.fire).toHaveBeenCalledWith(expect.objectContaining({ icon: "error" }));
});
it("retries a transient snapshot failure after an inline mutation", async () => {
let snapshotCalls = 0;
mocks.request.mockImplementation(async (url, method) => {
if (url === "/superuser/invoicing/period/tree" && method === "GET") {
snapshotCalls += 1;
if (snapshotCalls === 2) {
throw new Error("temporary snapshot failure");
}
return snapshotResponse(`rev-retry-${snapshotCalls}`, { queue_economic: true });
}
return { data: { data: [] } };
});
Swal.fire.mockResolvedValueOnce({ isConfirmed: true, value: "135" });
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_items");
await wrapper.get('[data-testid="invoice-period-tree-field-editor-order_item:501-price"]').trigger("click");
await flushPromises();
expect(snapshotCalls).toBe(3);
expect(
wrapper
.get('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]')
.attributes("data-action-count")
).toBe("1");
});
it("keeps a visible expand-all toggle and reuses loaded branches after collapse", async () => {
const wrapper = mountTree();
const toggle = () => wrapper.get('[data-testid="invoice-period-tree-toggle-all"]');
expect(wrapper.get('[data-testid="invoice-period-tree-toolbar"]').exists()).toBe(true);
expect(
wrapper.find('[data-testid="invoice-period-tree-toolbar"] .invoice-period-tree-toolbar__actions').exists()
).toBe(false);
expect(toggle().text()).toContain("Udfold alle");
expect(toggle().attributes("aria-pressed")).toBe("false");
await toggle().trigger("click");
await flushPromises();
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:3001:collection_orders"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
expect(toggle().text()).toContain("Fold alle sammen");
expect(toggle().attributes("aria-pressed")).toBe("true");
const loadCallCount = mocks.request.mock.calls.length;
await toggle().trigger("click");
await flushPromises();
expect(wrapper.get('[data-node-key="collected_order_invoice:3001"]').classes()).not.toContain("is-expanded");
expect(toggle().text()).toContain("Udfold alle");
await toggle().trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(loadCallCount);
expect(toggle().text()).toContain("Fold alle sammen");
});
it("fully expands lazy content by default for invoice-per-order views", async () => {
const wrapper = mountTree({ autoExpandAll: true });
await flushPromises();
expect(wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').text()).toContain("Fold alle sammen");
expect(wrapper.get('[data-node-key="order:9001"]').classes()).toContain("is-expanded");
expect(wrapper.get('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
});
it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
const collectionWheel = wrapper.find(
'[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]'
);
const orderWheel = wrapper.find('[data-testid="invoice-period-tree-action-wheel-order:9001"]');
const orderItemWheel = wrapper.find('[data-testid="invoice-period-tree-action-wheel-order_item:501"]');
expect(collectionWheel.exists()).toBe(true);
expect(orderWheel.exists()).toBe(true);
expect(orderItemWheel.exists()).toBe(true);
expect(collectionWheel.attributes("data-action-count")).toBe("4");
expect(collectionWheel.attributes("data-invoice-collection-id")).toBe("3001");
expect(orderWheel.attributes("data-action-count")).toBe("3");
expect(orderWheel.attributes("data-order-id")).toBe("9001");
expect(orderWheel.attributes("data-department-id")).toBe("75");
expect(orderItemWheel.attributes("data-action-count")).toBe("1");
expect(wrapper.find('[data-testid="invoice-period-tree-action-wheel-category:9001:order_items"]').exists()).toBe(
false
);
});
it("shows empty editable fields without a reveal toggle", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-notes"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-value-order:9001-notes"]').text()).toContain("Tom");
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-registrations"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-reg_2"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-reg_3"]').exists()).toBe(false);
expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false);
});
it("uses wash labels, nests related order items, and omits product ids and type tags", async () => {
mocks.orderItems.push({
id: 502,
order_id: 9001,
related_item_id: 501,
product_id: 702,
product_name: "Interior wash with a complete descriptive product label",
quantity: 1,
price: 45,
});
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "order_item:501");
expect(wrapper.get('[data-node-key="order:9001"]').text()).toContain("Vask #9001");
expect(wrapper.find('[data-node-key="order_item:501"] [data-node-key="order_item:502"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Interior wash with a complete descriptive product label");
expect(wrapper.find('[data-testid$="-product_id"]').exists()).toBe(false);
expect(wrapper.find(".tag.is-white").exists()).toBe(false);
});
it("places entity and field flags on their exact labels", async () => {
const wrapper = mountTree({
invoicePeriodFlags: [
{ id: 1, source: "manual", target_type: "order", target_id: 9001, status: "active" },
{
id: 2,
source: "automatic",
target_type: "order_item_field",
target_id: 501,
field: "price",
status: "active",
},
{
id: 3,
source: "automatic",
target_type: "order_field",
target_id: 9006,
field: "reference",
invoice_collection_id: 3001,
order_id: 9006,
status: "active",
},
],
});
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(
wrapper
.get('[data-node-key="order:9001"] .invoice-period-tree-node__identity .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get(
'[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__identity .invoice-period-flag-badge-stub'
)
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get('[data-testid="invoice-period-tree-field-order_item:501-price"] .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"] .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("0");
});
it("omits redundant collection name and customer number fields", async () => {
const wrapper = mountTree();
expect(wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-notes"]').exists()).toBe(
true
);
expect(wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-name"]').exists()).toBe(
false
);
expect(
wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-customer_number"]').exists()
).toBe(false);
});
it("renders collection fields from the period collection summary", () => {
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 3001,
state: "closed",
notes: "Summary note",
po_number: "PO-SUMMARY",
external_id: "EXT-3001",
closed_at: "2026-06-30 12:00:00",
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
});
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-notes"]').text()
).toContain("Summary note");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-po_number"]').text()
).toContain("PO-SUMMARY");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-external_id"]').text()
).toContain("EXT-3001");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-closed_at"]').text()
).toContain("2026-06-30");
});
it("renders only resolved draft/booked e-conomic invoice children", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
expect(mocks.economicDetails).toHaveBeenCalledWith(3001);
expect(wrapper.text()).not.toContain("E-conomic kladde #88");
expect(wrapper.text()).toContain("E-conomic faktura #99");
});
it("exposes summary-only booked invoices when no transaction or normalized details are available", async () => {
mocks.economicDetails.mockRejectedValueOnce(new Error("Details unavailable"));
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4401,
state: "booked",
booked_invoice_id: 551,
total_net_amount: 875,
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
transactions: [],
});
await expandNode(wrapper, "collected_order_invoice:4401");
expect(wrapper.find('[data-node-key="category:4401:collection_economic"]').exists()).toBe(true);
await expandNode(wrapper, "category:4401:collection_economic");
const bookedInvoiceNode = wrapper.find('[data-node-key="economic_invoice:4401:booked:551"]');
expect(bookedInvoiceNode.exists()).toBe(true);
await bookedInvoiceNode.find(".b-tree-node-content").trigger("click");
await flushPromises();
expect(mocks.economicPdf).toHaveBeenCalledWith(4401, "booked");
});
it("exposes summary-only draft invoices from their identifier and state", async () => {
mocks.economicDetails.mockResolvedValueOnce({ data: { data: {} } });
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 4402,
state: "draft",
draft_invoice_id: 552,
total_net_amount: 875,
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
transactions: [],
});
await expandNode(wrapper, "collected_order_invoice:4402");
await expandNode(wrapper, "category:4402:collection_economic");
expect(wrapper.find('[data-node-key="economic_invoice:4402:draft:552"]').exists()).toBe(true);
});
it("keeps a persisted booked invoice downloadable when normalized booked details are unavailable", async () => {
mocks.economicDetails.mockResolvedValueOnce({
data: {
data: {
economic: { state: "booked", available_pdf_type: "booked", draft_id: 88, booked_id: 99 },
draft: { exists: false, normalized: null },
booked: { exists: false, normalized: null },
warnings: ["Invoice draft was not found"],
},
},
});
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
expect(wrapper.find('[data-node-key="economic_invoice:3001:booked:99"]').exists()).toBe(true);
expect(wrapper.text()).not.toContain("E-conomic kladde #88");
});
it("colors collection, invoice, order, and item icons from the booked state", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
expect(
wrapper
.get('[data-node-key="collected_order_invoice:3001"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
expect(
wrapper
.get('[data-node-key="category:3001:collection_orders"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
expect(
wrapper
.get('[data-node-key="category:3001:collection_economic"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(
wrapper.get('[data-node-key="order:9001"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
expect(
wrapper.get('[data-node-key="category:9001:order_items"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
expect(
wrapper.get('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
});
it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
const bookedInvoiceNode = wrapper.find('[data-node-key="economic_invoice:3001:booked:99"]');
expect(bookedInvoiceNode.exists()).toBe(true);
await bookedInvoiceNode.find(".b-tree-node-content").trigger("click");
await flushPromises();
expect(mocks.economicPdf).toHaveBeenCalledWith(3001, "booked");
expect(window.open).toHaveBeenCalledWith("https://example.test/invoice.pdf", "_blank", "noopener,noreferrer");
});
it("fetches attachment hover previews as authenticated inline blobs", async () => {
mocks.order.attachments = [{ id: 601, order_id: 9001, content: { other: "wash_certificate" } }];
const blob = new Blob(["pdf"], { type: "application/pdf" });
SessionUser.objects.orders.functions.fetchAttachmentContent.mockResolvedValueOnce(blob);
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_attachments_certificates");
await wrapper.get('[data-testid="invoice-period-tree-node-attachment:601:9001"]').trigger("mouseenter");
await flushPromises();
expect(SessionUser.objects.orders.functions.fetchAttachmentContent).toHaveBeenCalledWith(9001, 601, "inline");
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
expect(
wrapper.get('[data-testid="invoice-period-tree-preview-attachment:601:9001"] iframe').attributes("src")
).toBe("blob:attachment-preview");
wrapper.unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment-preview");
});
it("uses the complete snapshot only when advertised and hides disabled collection actions", async () => {
mocks.request.mockResolvedValueOnce(snapshotResponse("rev-capabilities", { queue_economic: true }));
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
expect(mocks.request).toHaveBeenCalledWith(
"/superuser/invoicing/period/tree",
"GET",
{ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" },
null,
null,
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
const collectionWheel = wrapper.get(
'[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]'
);
expect(collectionWheel.attributes("data-action-count")).toBe("1");
expect(
wrapper.get('[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__subtitle').text()
).toContain("Periode: 1 orders · 240 DKK");
expect(wrapper.text()).toContain("Hele samlingen: 2 orders · 300 DKK");
await wrapper.get('[data-testid="invoice-period-tree-toggle-all"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-node-key="order:9002"]').exists()).toBe(true);
expect(mocks.request.mock.calls.some(([url]) => url === "/order/items")).toBe(false);
});
it("refreshes an active snapshot when parent transaction data changes", async () => {
mocks.request
.mockResolvedValueOnce(snapshotResponse("rev-parent-1", { queue_economic: true }))
.mockResolvedValueOnce(snapshotResponse("rev-parent-2", { queue_economic: true }));
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await wrapper.setProps({
transactions: [{ ...mocks.order, booked: true, total_net_amount: 245 }],
});
await flushPromises();
expect(mocks.request.mock.calls.filter(([url]) => url === "/superuser/invoicing/period/tree")).toHaveLength(2);
});
it("creates and confirms a legacy preview before falling back from tree apply", async () => {
const actions = { queue_economic: true };
mocks.request.mockResolvedValueOnce(snapshotResponse("rev-fallback", actions));
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_preview.mockResolvedValueOnce({
data: {
data: {
preview_id: "tree-preview",
confirmation_phrase: "CONFIRM TREE",
summary: { collection_count: 1, changed_count: 1 },
blockers: [],
},
},
});
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply.mockRejectedValueOnce({
response: { status: 404 },
});
SessionUser.objects.collectedOrderInvoices.functions.bulk_action_preview.mockResolvedValueOnce({
data: {
data: {
preview_id: "legacy-preview",
confirmation_phrase: "CONFIRM LEGACY",
summary: { collection_count: 1, changed_count: 1 },
blockers: [],
},
},
});
SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply.mockResolvedValueOnce({
data: { data: { jobs: [{ id: 1 }] } },
});
Swal.fire
.mockResolvedValueOnce({ isConfirmed: true, value: "CONFIRM TREE" })
.mockResolvedValueOnce({ isConfirmed: true, value: "CONFIRM LEGACY" });
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await wrapper.get('[data-node-key="collected_order_invoice:3001"] .b-checkbox-stub').trigger("click");
await flushPromises();
await wrapper.get('[data-testid="invoice-period-tree-actions-trigger-collected_order_invoice"]').trigger("click");
await wrapper.get('[data-testid="invoice-period-tree-action-collection:queue-economic"]').trigger("click");
await flushPromises();
expect(SessionUser.objects.collectedOrderInvoices.functions.bulk_action_preview).toHaveBeenCalledTimes(1);
expect(SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply).toHaveBeenCalledWith(
expect.objectContaining({
preview_id: "legacy-preview",
confirmation_text: "CONFIRM LEGACY",
})
);
expect(Swal.fire.mock.calls.some(([options]) => options?.icon === "success")).toBe(true);
expect(Swal.fire.mock.calls.some(([options]) => options?.icon === "error")).toBe(false);
});
it("keeps legacy collection mutations disabled while a superseding snapshot request is pending", async () => {
const pending = [];
mocks.request.mockImplementation((url, _method, _parameters, _catcher, _then, options) =>
url === "/superuser/invoicing/period/tree"
? new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" }))
);
pending.push({ resolve, signal: options.signal });
})
: Promise.resolve({ data: { data: [] } })
);
const wrapper = mountTree({ capabilities: { object_tree_v2: false } });
await wrapper.setProps({
dates: { dateFrom: "2026-07-01", dateTo: "2026-07-31" },
capabilities: { object_tree_v2: true },
});
await flushPromises();
expect(pending).toHaveLength(2);
expect(pending[0].signal.aborted).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-snapshot-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]').exists()).toBe(
false
);
pending[1].resolve({
...snapshotResponse("rev-overlap", { queue_economic: true }),
data: {
data: {
...snapshotResponse("rev-overlap", { queue_economic: true }).data.data,
date_from: "2026-07-01",
date_to: "2026-07-31",
},
},
});
await flushPromises();
expect(wrapper.find('[data-testid="invoice-period-tree-snapshot-loading"]').exists()).toBe(false);
expect(
wrapper
.get('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]')
.attributes("data-action-count")
).toBe("1");
});
it("refreshes the complete snapshot after a stale-revision 409", async () => {
const actions = { queue_economic: true };
mocks.request
.mockResolvedValueOnce(snapshotResponse("rev-stale", actions))
.mockResolvedValueOnce(snapshotResponse("rev-current", actions));
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_preview.mockResolvedValueOnce({
data: {
data: {
preview_id: "preview-1",
confirmation_phrase: "CONFIRM",
summary: { collection_count: 1, changed_count: 1 },
blockers: [],
},
},
});
SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply.mockRejectedValueOnce({
response: { status: 409 },
message: "Snapshot is stale",
});
Swal.fire.mockResolvedValueOnce({ isConfirmed: true, value: "CONFIRM" });
const wrapper = mountTree({ capabilities: { object_tree_v2: true } });
await flushPromises();
await wrapper.get('[data-node-key="collected_order_invoice:3001"] .b-checkbox-stub').trigger("click");
await flushPromises();
await wrapper.get('[data-testid="invoice-period-tree-actions-trigger-collected_order_invoice"]').trigger("click");
await wrapper.get('[data-testid="invoice-period-tree-action-collection:queue-economic"]').trigger("click");
await flushPromises();
expect(SessionUser.objects.collectedOrderInvoices.functions.period_tree_action_apply).toHaveBeenCalledWith({
preview_id: "preview-1",
confirmation_text: "CONFIRM",
});
expect(mocks.request.mock.calls.filter(([url]) => url === "/superuser/invoicing/period/tree")).toHaveLength(2);
expect(SessionUser.objects.collectedOrderInvoices.functions.bulk_action_apply).not.toHaveBeenCalled();
});
});