// @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(); }); });