- Introduced `.prettierrc.json` to enforce consistent code formatting across the project. - Updated unit and e2e test files to address formatting issues, improve readability, and ensure alignment with the new Prettier configuration.
537 lines
16 KiB
JavaScript
537 lines
16 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/components/session/token/SessionUser.vue", () => {
|
|
const request = vi.fn();
|
|
const parseErrorMessage = vi.fn((error) => error?.message || String(error || "Unknown error"));
|
|
|
|
return {
|
|
SessionUser: {
|
|
request,
|
|
functions: {
|
|
parseErrorMessage,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import {
|
|
compareCollectedInvoicesForMonth,
|
|
fetchMonthData,
|
|
} from "@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js";
|
|
|
|
const buildInvoice = (id) => ({
|
|
id,
|
|
customer_number: 2000 + id,
|
|
customer_name: `Customer ${id}`,
|
|
total_net_amount: 100,
|
|
});
|
|
|
|
const buildV2Category = (amount) => ({
|
|
customers: [
|
|
{
|
|
id: 1,
|
|
customer_number: 5001,
|
|
customer_name: "ACME",
|
|
requires_action: false,
|
|
transactions: [{ id: 1, amount, booked: true, excluded: false, department_id: 1, date: "2026-03-01T00:00:00Z" }],
|
|
meta: {
|
|
fixed_pricing: {
|
|
price: amount,
|
|
department_totals_relative: { 1: amount },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_amount: amount,
|
|
department_distribution_parsed: { Ops: amount },
|
|
},
|
|
warnings: [],
|
|
});
|
|
|
|
const buildV2CompareResult = (invoiceId, { internalTotal = 100, bookedTotal = 100, mismatch = false } = {}) => ({
|
|
collected_invoice_id: invoiceId,
|
|
warnings: mismatch ? ["Top level warning"] : [],
|
|
details: {
|
|
order_ids: mismatch ? [1, 2] : [1],
|
|
customer: {
|
|
internal_customer_number: 7000 + invoiceId,
|
|
name: `Customer ${invoiceId}`,
|
|
},
|
|
internal: {
|
|
normalized: {
|
|
totals: {
|
|
net_total: internalTotal,
|
|
billable_line_count: mismatch ? 2 : 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
comparison: {
|
|
totals: {
|
|
internal_net_total: internalTotal,
|
|
},
|
|
targets: {
|
|
booked: {
|
|
target: "booked",
|
|
status: mismatch ? "partial_mismatch" : "exact_match",
|
|
overall_match: !mismatch,
|
|
totals: {
|
|
target_net_total: bookedTotal,
|
|
difference: internalTotal - bookedTotal,
|
|
},
|
|
mismatch_reasons: mismatch ? ["department_total_mismatch"] : [],
|
|
warnings: mismatch ? ["Line mismatch"] : [],
|
|
lines: {
|
|
summary: {
|
|
internal_billable_count: mismatch ? 2 : 1,
|
|
target_billable_count: mismatch ? 2 : 1,
|
|
mismatch_count: mismatch ? 1 : 0,
|
|
},
|
|
diff: [],
|
|
},
|
|
departments: {
|
|
matches: !mismatch,
|
|
diff: [],
|
|
},
|
|
},
|
|
},
|
|
warnings: [],
|
|
},
|
|
});
|
|
|
|
describe("invoice distribution api adapter", () => {
|
|
let consoleWarnSpy;
|
|
|
|
beforeEach(() => {
|
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
SessionUser.request.mockReset();
|
|
SessionUser.functions.parseErrorMessage.mockReset();
|
|
SessionUser.functions.parseErrorMessage.mockImplementation(
|
|
(error) => error?.message || String(error || "Unknown error")
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleWarnSpy?.mockRestore();
|
|
});
|
|
|
|
it("uses v2 all endpoint for month distribution when available", async () => {
|
|
SessionUser.request.mockImplementation(async (endpoint) => {
|
|
if (endpoint === "/superuser/invoicing/period") {
|
|
return {
|
|
data: {
|
|
data: {
|
|
types: {
|
|
all: [
|
|
{
|
|
transactions: [{ amount: 200, booked: true, excluded: false }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/v2/all") {
|
|
return {
|
|
data: {
|
|
fixed_pricing: buildV2Category(80),
|
|
wash_subscriptions: buildV2Category(70),
|
|
customer_prices: buildV2Category(50),
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await fetchMonthData(2026, 3);
|
|
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
|
expect(result.fallback.usedSplitV2Distribution).toBe(false);
|
|
expect(result.summary.distributionAmount).toBe(150);
|
|
expect(result.summary.customerPriceAmount).toBe(50);
|
|
expect(result.summary.totalAmount).toBe(350);
|
|
expect(SessionUser.request).toHaveBeenCalledWith(
|
|
"/superuser/invoicing/period/distribution/v2/all",
|
|
"GET",
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it("falls back to v2 split endpoints when v2 all fails", async () => {
|
|
SessionUser.request.mockImplementation(async (endpoint) => {
|
|
if (endpoint === "/superuser/invoicing/period") {
|
|
return {
|
|
data: {
|
|
data: {
|
|
types: {
|
|
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/v2/all") {
|
|
throw new Error("v2 all unavailable");
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/v2/fixed-pricing") {
|
|
return { data: buildV2Category(40) };
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/v2/wash-subscriptions") {
|
|
return { data: buildV2Category(30) };
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/v2/customer-prices") {
|
|
return { data: buildV2Category(20) };
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await fetchMonthData(2026, 3);
|
|
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
|
expect(result.fallback.usedSplitV2Distribution).toBe(true);
|
|
expect(result.fallback.distributionFallbackReason).toContain("v2 all unavailable");
|
|
expect(result.summary.distributionAmount).toBe(70);
|
|
expect(result.summary.customerPriceAmount).toBe(20);
|
|
expect(result.summary.totalAmount).toBe(190);
|
|
});
|
|
|
|
it("falls back to legacy distribution endpoints when all v2 paths fail", async () => {
|
|
SessionUser.request.mockImplementation(async (endpoint) => {
|
|
if (endpoint === "/superuser/invoicing/period") {
|
|
return {
|
|
data: {
|
|
data: {
|
|
types: {
|
|
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (
|
|
endpoint === "/superuser/invoicing/period/distribution/v2/all" ||
|
|
endpoint === "/superuser/invoicing/period/distribution/v2/fixed-pricing" ||
|
|
endpoint === "/superuser/invoicing/period/distribution/v2/wash-subscriptions" ||
|
|
endpoint === "/superuser/invoicing/period/distribution/v2/customer-prices"
|
|
) {
|
|
throw new Error(`${endpoint} unavailable`);
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/fixed-pricing") {
|
|
return {
|
|
data: {
|
|
customers: [
|
|
{
|
|
id: 1,
|
|
customer_number: 1001,
|
|
customer_name: "Legacy Fixed",
|
|
meta: { fixed_pricing: { price: 50 } },
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_fixed_price: 50,
|
|
total_department_totals_relative_parsed: { Ops: 50 },
|
|
},
|
|
warnings: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/superuser/invoicing/period/distribution/wash-subscriptions") {
|
|
return {
|
|
data: {
|
|
customers: [
|
|
{
|
|
id: 2,
|
|
customer_number: 1002,
|
|
customer_name: "Legacy Subscription",
|
|
meta: { wash_subscription: { price: 25 } },
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_subscription_price: 25,
|
|
subscription_price_department_distribution_parsed: { Ops: 25 },
|
|
},
|
|
warnings: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await fetchMonthData(2026, 3);
|
|
expect(result.fallback.usedLegacyDistribution).toBe(true);
|
|
expect(result.fallback.distributionFallbackReason).toContain(
|
|
"/superuser/invoicing/period/distribution/v2/all unavailable"
|
|
);
|
|
expect(result.summary.fixedPricingAmount).toBe(50);
|
|
expect(result.summary.subscriptionAmount).toBe(25);
|
|
expect(result.summary.distributionAmount).toBe(75);
|
|
expect(result.summary.totalAmount).toBe(195);
|
|
});
|
|
|
|
it("chunks v2 bulk compare requests to max 200 ids", async () => {
|
|
const invoices = Array.from({ length: 250 }, (_, index) => buildInvoice(index + 1));
|
|
|
|
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
|
if (endpoint === "/collected-invoices" && method === "GET") {
|
|
const page = Number(params?.page || 1);
|
|
const perPage = 100;
|
|
const start = (page - 1) * perPage;
|
|
const data = invoices.slice(start, start + perPage);
|
|
return {
|
|
data: {
|
|
data,
|
|
meta: {
|
|
pagination: {
|
|
total: invoices.length,
|
|
per_page: perPage,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare/bulk" && method === "POST") {
|
|
const ids = params?.collected_invoice_ids || [];
|
|
return {
|
|
data: {
|
|
data: {
|
|
requested: ids.length,
|
|
compared: ids.length,
|
|
failed: 0,
|
|
results: ids.map((id) => buildV2CompareResult(id)),
|
|
errors: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await compareCollectedInvoicesForMonth({
|
|
year: 2026,
|
|
month: 3,
|
|
mode: "invoice_total",
|
|
batchSize: 500,
|
|
});
|
|
|
|
const bulkCalls = SessionUser.request.mock.calls.filter(
|
|
([endpoint]) => endpoint === "/collected-invoices/economic/v2/compare/bulk"
|
|
);
|
|
expect(bulkCalls).toHaveLength(2);
|
|
expect(bulkCalls[0][2].collected_invoice_ids).toHaveLength(200);
|
|
expect(bulkCalls[1][2].collected_invoice_ids).toHaveLength(50);
|
|
expect(result.invoiceCount).toBe(250);
|
|
expect(result.fallback.usedLegacyCompare).toBe(false);
|
|
});
|
|
|
|
it("emits incremental compare progress and row chunks while v2 compare runs", async () => {
|
|
const invoices = [buildInvoice(1), buildInvoice(2), buildInvoice(3), buildInvoice(4), buildInvoice(5)];
|
|
const onRows = vi.fn();
|
|
const onProgress = vi.fn();
|
|
|
|
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
|
if (endpoint === "/collected-invoices" && method === "GET") {
|
|
return {
|
|
data: {
|
|
data: invoices,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare/bulk" && method === "POST") {
|
|
const ids = params?.collected_invoice_ids || [];
|
|
return {
|
|
data: {
|
|
data: {
|
|
requested: ids.length,
|
|
compared: ids.length,
|
|
failed: 0,
|
|
results: ids.map((id) => buildV2CompareResult(id)),
|
|
errors: [],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await compareCollectedInvoicesForMonth({
|
|
year: 2026,
|
|
month: 3,
|
|
mode: "invoice_total",
|
|
batchSize: 2,
|
|
onRows,
|
|
onProgress,
|
|
});
|
|
|
|
expect(result.rows).toHaveLength(5);
|
|
expect(onRows).toHaveBeenCalledTimes(5);
|
|
expect(onRows.mock.calls.map(([rows]) => rows.length)).toEqual([1, 1, 1, 1, 1]);
|
|
expect(onProgress.mock.calls.map(([progress]) => `${progress.processed}/${progress.total}`)).toEqual([
|
|
"0/5",
|
|
"1/5",
|
|
"2/5",
|
|
"3/5",
|
|
"4/5",
|
|
"5/5",
|
|
]);
|
|
});
|
|
|
|
it("recovers missing bulk compare rows via v2 single endpoint", async () => {
|
|
const invoices = [buildInvoice(101), buildInvoice(102)];
|
|
|
|
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
|
if (endpoint === "/collected-invoices" && method === "GET") {
|
|
return {
|
|
data: {
|
|
data: invoices,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare/bulk" && method === "POST") {
|
|
return {
|
|
data: {
|
|
data: {
|
|
requested: params?.collected_invoice_ids?.length || 0,
|
|
compared: 1,
|
|
failed: 1,
|
|
results: [buildV2CompareResult(101)],
|
|
errors: [{ collected_invoice_id: 102, error: "temporary mismatch in bulk compare" }],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare" && method === "GET") {
|
|
return {
|
|
data: {
|
|
data: buildV2CompareResult(102, { mismatch: true, internalTotal: 110, bookedTotal: 90 }),
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await compareCollectedInvoicesForMonth({
|
|
year: 2026,
|
|
month: 3,
|
|
mode: "line_by_line",
|
|
});
|
|
|
|
expect(result.fallback.usedLegacyCompare).toBe(false);
|
|
expect(result.fallback.usedSingleCompareFallback).toBe(true);
|
|
expect(result.fallback.compareFallbackReason).toContain("temporary mismatch in bulk compare");
|
|
expect(result.rows).toHaveLength(2);
|
|
expect(result.rows.some((row) => row.invoiceId === 102)).toBe(true);
|
|
});
|
|
|
|
it("falls back to legacy compare when v2 bulk compare fails", async () => {
|
|
const invoices = [buildInvoice(101), buildInvoice(102)];
|
|
|
|
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
|
if (endpoint === "/collected-invoices" && method === "GET") {
|
|
return {
|
|
data: {
|
|
data: invoices,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare/bulk" && method === "POST") {
|
|
throw new Error("v2 compare failed");
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/compare" && method === "GET") {
|
|
const invoiceId = Number(params?.collected_invoice_id);
|
|
return {
|
|
data: {
|
|
data: {
|
|
collected_invoice_id: invoiceId,
|
|
internal_total: 100,
|
|
booked_total: 95,
|
|
difference: 5,
|
|
warnings: [],
|
|
order_ids: [invoiceId],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await compareCollectedInvoicesForMonth({
|
|
year: 2026,
|
|
month: 3,
|
|
mode: "invoice_total",
|
|
legacyBatchSize: 10,
|
|
});
|
|
|
|
const legacyCalls = SessionUser.request.mock.calls.filter(
|
|
([endpoint]) => endpoint === "/collected-invoices/economic/compare"
|
|
);
|
|
expect(legacyCalls).toHaveLength(2);
|
|
expect(result.fallback.usedLegacyCompare).toBe(true);
|
|
expect(result.fallback.compareFallbackReason).toContain("v2 compare failed");
|
|
expect(result.rows).toHaveLength(2);
|
|
});
|
|
|
|
it("creates mismatch fallback row when both bulk and single v2 data are missing", async () => {
|
|
const invoices = [buildInvoice(301)];
|
|
|
|
SessionUser.request.mockImplementation(async (endpoint, method) => {
|
|
if (endpoint === "/collected-invoices" && method === "GET") {
|
|
return { data: { data: invoices } };
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare/bulk" && method === "POST") {
|
|
return {
|
|
data: {
|
|
data: {
|
|
requested: 1,
|
|
compared: 0,
|
|
failed: 1,
|
|
results: [],
|
|
errors: [{ collected_invoice_id: 301, error: "no bulk result" }],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (endpoint === "/collected-invoices/economic/v2/compare" && method === "GET") {
|
|
throw new Error("single compare down");
|
|
}
|
|
|
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
|
});
|
|
|
|
const result = await compareCollectedInvoicesForMonth({
|
|
year: 2026,
|
|
month: 3,
|
|
mode: "invoice_total",
|
|
});
|
|
|
|
expect(result.fallback.usedLegacyCompare).toBe(false);
|
|
expect(result.fallback.usedSingleCompareFallback).toBe(true);
|
|
expect(result.rows).toHaveLength(1);
|
|
expect(result.rows[0].status).toBe("mismatch");
|
|
expect(result.rows[0].warnings.join(" ")).toContain("single compare down");
|
|
});
|
|
});
|