import { describe, expect, it, vi } from "vitest"; import { buildCompleteSnapshotRootNodes, createInvoicingPeriodTreeSnapshotLoader, isTreeEndpointUnavailable, normalizeInvoicingPeriodTreeSnapshot, } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeSnapshot.ts"; import { normalizeInvoiceCollectionActionPreview, renderInvoiceCollectionActionPreviewHtml, } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts"; const payload = (revision = "rev-1") => ({ complete: true, snapshot_revision: revision, customer_number: 2001, date_from: "2026-06-01", date_to: "2026-06-30", capabilities: { object_tree_v2: true, actions: { merge_collections: true, queue_economic: false }, }, customer: { customer_number: 2001, customer_name: "ACME" }, collections: [ { id: 3001, in_selected_period: true, complete_order_count: 2, complete_total_net_amount: 300, period_order_count: 1, period_total_net_amount: 200, orders: [ { id: 9001, invoice_collection_id: 3001, in_selected_period: true, total_net_amount: 200, items: [ { id: 501, product_name: "Wash", quantity: 1, price: 200 }, { id: 502, related_item_id: 501, product_name: "Interior", quantity: 1, price: 25 }, ], attachments: [], bookings: [], xlvask: [], }, { id: 9002, invoice_collection_id: 3001, in_selected_period: false, total_net_amount: 100, items: [], attachments: [], bookings: [], xlvask: [], }, ], }, ], uncollected_orders: [], agreements: [], payments: [], economic_invoices: [], }); const labels = { collection: (id) => `Collection ${id}`, ordersWithoutCollection: "Loose orders", orders: "Orders", items: "Items", attachments: { certificates: "Certificates", images: "Images", other: "Attachments" }, bookings: "Bookings", booking: (id) => `Booking ${id}`, xlvask: "XL Vask", economic: "Economic", agreement: (id) => `Agreement ${id}`, payment: (id) => `Payment ${id}`, order: (id) => `Order ${id}`, orderItemFallback: (id) => `Item ${id}`, attachmentFallback: (id) => `Attachment ${id}`, bookingItemFallback: (id) => `Booking item ${id}`, xlvaskItemFallback: (index) => `XL item ${index}`, }; describe("invoicing period selected-customer tree snapshot", () => { it("keeps date-range collection labels when building the complete snapshot tree", () => { const rangePayload = payload("rev-range"); rangePayload.date_from = "2026-01-01"; rangePayload.date_to = "2026-01-31"; rangePayload.collections[0].orders[0].created_at = "2026-01-01 10:00:00"; rangePayload.collections[0].orders[1].created_at = "2026-01-31 10:00:00"; rangePayload.collections[0].orders[1].in_selected_period = true; const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: rangePayload } }); const roots = buildCompleteSnapshotRootNodes(snapshot, { ...labels, locale: "da-DK" }); expect(roots[0].label).toBe("1 Januar → 31 Januar"); expect(roots[0].meta.relativeLabel).toMatchObject({ kind: "range", startLabel: "1 Januar", endLabel: "31 Januar", }); }); it("normalizes a complete capability-gated snapshot and keeps off-period orders in the complete collection", () => { const snapshot = normalizeInvoicingPeriodTreeSnapshot({ data: { data: payload() } }); expect(snapshot).toMatchObject({ complete: true, source: "snapshot", snapshot_revision: "rev-1", capabilities: { object_tree_v2: true }, }); const roots = buildCompleteSnapshotRootNodes(snapshot, labels); expect(roots[0].label).toBe("Collection 3001"); expect(roots[0].meta).toMatchObject({ periodOrderCount: 1, periodTotalNetAmount: 200, completeOrderCount: 2, completeTotalNetAmount: 300, offPeriodOrderCount: 1, offPeriodTotalNetAmount: 100, }); const orderNodes = roots[0].children[0].children; expect(orderNodes.map((node) => node.id)).toEqual(["order:9001", "order:9002"]); expect(orderNodes[1].meta.inSelectedPeriod).toBe(false); expect(orderNodes[0].children[0].id).toBe("order_item:501"); expect(orderNodes[0].children[0].meta.showTableHeader).toBe(true); expect(orderNodes[0].children[0].meta.orderId).toBe(9001); expect(orderNodes[0].children[1]).toMatchObject({ id: "order_item:502", isLeaf: true, meta: { orderId: 9001, tableDepth: 1, showTableHeader: false }, }); expect(orderNodes[0].children.some((node) => node.category === "order_items")).toBe(false); }); it("rejects incomplete or capability-disabled payloads", () => { expect(normalizeInvoicingPeriodTreeSnapshot({ ...payload(), complete: false })).toBeNull(); expect( normalizeInvoicingPeriodTreeSnapshot({ ...payload(), capabilities: { object_tree_v2: false, actions: {} }, }) ).toBeNull(); }); it("deduplicates identical loads and aborts a superseded customer request", async () => { const pending = []; const request = vi.fn( (_url, _method, parameters, _catcher, _then, options) => new Promise((resolve, reject) => { options.signal.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })) ); pending.push({ parameters, resolve, signal: options.signal }); }) ); const loader = createInvoicingPeriodTreeSnapshotLoader(request); const first = loader.load({ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" }); const duplicate = loader.load({ customerNumber: 2001, dateFrom: "2026-06-01", dateTo: "2026-06-30" }); expect(duplicate).toBe(first); const second = loader.load({ customerNumber: 2002, dateFrom: "2026-06-01", dateTo: "2026-06-30" }); await expect(first).rejects.toMatchObject({ name: "AbortError" }); expect(pending[0].signal.aborted).toBe(true); pending[1].resolve({ data: { data: { ...payload("rev-2"), customer_number: 2002, customer: { customer_number: 2002 } } }, }); await expect(second).resolves.toMatchObject({ snapshot_revision: "rev-2", customer_number: 2002 }); expect(request).toHaveBeenCalledTimes(2); }); it("does not classify internal request cancellation as endpoint unavailability", () => { expect(isTreeEndpointUnavailable(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe(false); expect(isTreeEndpointUnavailable({ code: "ERR_CANCELED" })).toBe(false); expect(isTreeEndpointUnavailable({ response: { status: 404 } })).toBe(true); expect(isTreeEndpointUnavailable({ response: { status: 501 } })).toBe(true); }); it("renders escaped concrete changes, merge target, and off-period impact", () => { const preview = normalizeInvoiceCollectionActionPreview({ preview_id: "preview-1", confirmation_phrase: "CONFIRM", summary: { collection_count: 2, changed_count: 1, off_period_order_count: 1, off_period_total_net_amount: 100 }, target_invoice_collection_id: 3002, changes: [{ message: "Move " }], }); const html = renderInvoiceCollectionActionPreviewHtml({ preview, t: (key, params = {}) => `${key}:${JSON.stringify(params)}`, formatCurrency: (value) => `${value} DKK`, }); expect(html).toContain("3002"); expect(html).toContain("1"); expect(html).toContain("100 DKK"); expect(html).toContain("<script>"); expect(html).not.toContain("