Add the complete selected-customer invoice collection tree, revision-bound actions, fallback handling, and focused frontend coverage.
883 lines
31 KiB
JavaScript
883 lines
31 KiB
JavaScript
// @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: `
|
|
<button
|
|
class="b-checkbox-stub"
|
|
type="button"
|
|
:disabled="disabled"
|
|
:data-checked="modelValue"
|
|
:data-indeterminate="indeterminate"
|
|
@click="$emit('update:modelValue', !modelValue)"
|
|
></button>
|
|
`,
|
|
},
|
|
}));
|
|
|
|
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"],
|
|
template: `<span class="editable-table-column-stub">{{ parseFunction ? parseFunction(object?.[column]) : object?.[column] }}</span>`,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({
|
|
default: {
|
|
name: "ActionSettingsWheelButton",
|
|
props: ["menuSections", "order_id", "department_id", "invoice_collection_id", "customer_number"],
|
|
template: `
|
|
<button
|
|
type="button"
|
|
class="action-settings-wheel-stub"
|
|
:data-menu-count="menuSections.length"
|
|
:data-action-count="menuSections.reduce((count, section) => count + (section.items?.length || 0), 0)"
|
|
:data-order-id="order_id || undefined"
|
|
:data-department-id="department_id || undefined"
|
|
:data-invoice-collection-id="invoice_collection_id || undefined"
|
|
:data-customer-number="customer_number || undefined"
|
|
></button>
|
|
`,
|
|
},
|
|
}));
|
|
|
|
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: `<span class="invoice-period-flag-badge-stub" :data-flag-count="flags.length"></span>`,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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();
|
|
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("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("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();
|
|
});
|
|
});
|