d44ead41c5
26 tests as a safety net ahead of decomposition — invoice create/list/ status transitions, adminEvents CRUD via Supertest+SQLite, backup config parsing and manifest validation.
260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
/**
|
||
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||
* decomposition — createInvoice happy path (incl. the line-item
|
||
* totals/VAT math), list/get reads, and the status-transition guards
|
||
* on cancelInvoice / releaseForDelivery.
|
||
*
|
||
* Uses the same deep-mocked db pattern as
|
||
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||
* and assertions probe insert/update call shapes rather than SQL.
|
||
*/
|
||
|
||
const chains = [];
|
||
function makeChain() {
|
||
const c = {
|
||
_firstValue: undefined,
|
||
_updateResult: 1,
|
||
_insertResult: [{ id: 999 }],
|
||
_selectResult: [],
|
||
then: function (onResolve, onReject) {
|
||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||
},
|
||
where: jest.fn(function () { return this; }),
|
||
whereNot: jest.fn(function () { return this; }),
|
||
whereIn: jest.fn(function () { return this; }),
|
||
whereNull: jest.fn(function () { return this; }),
|
||
whereNotNull: jest.fn(function () { return this; }),
|
||
andWhere: jest.fn(function () { return this; }),
|
||
orderBy: jest.fn(function () { return this; }),
|
||
limit: jest.fn(function () { return this; }),
|
||
select: jest.fn(function () { return this; }),
|
||
sum: jest.fn(function () { return this; }),
|
||
count: jest.fn(function () { return this; }),
|
||
clone: jest.fn(function () { return this; }),
|
||
clearSelect: jest.fn(function () { return this; }),
|
||
clearOrder: jest.fn(function () { return this; }),
|
||
offset: jest.fn(function () { return this; }),
|
||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||
insert: jest.fn(function () { return this; }),
|
||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||
onConflict: jest.fn(function () { return this; }),
|
||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||
increment: jest.fn(function () { return this; }),
|
||
forUpdate: jest.fn(function () { return this; }),
|
||
leftJoin: jest.fn(function () { return this; }),
|
||
};
|
||
chains.push(c);
|
||
return c;
|
||
}
|
||
|
||
const tableChains = {};
|
||
function pickChainFor(name) {
|
||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||
return tableChains[name];
|
||
}
|
||
|
||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||
|
||
jest.mock('../../src/database/db', () => ({
|
||
db: mockDbFn,
|
||
withRetry: jest.fn(async (fn) => fn()),
|
||
logActivity: jest.fn(async () => {}),
|
||
}));
|
||
|
||
jest.mock('../../src/utils/appSettings', () => ({
|
||
getAppSetting: jest.fn(async () => null),
|
||
}));
|
||
|
||
jest.mock('../../src/services/businessProfileService', () => ({
|
||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||
}));
|
||
|
||
jest.mock('../../src/utils/documentSequences', () => {
|
||
const claimNextSequence = jest.fn(async () => 42);
|
||
// Delegates to the claimNextSequence mock so call-count assertions
|
||
// below keep observing sequence claims.
|
||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||
const seq = await claimNextSequence(kind, 2026, trx);
|
||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||
});
|
||
return { claimNextSequence, nextDocumentNumber };
|
||
});
|
||
|
||
jest.mock('../../src/services/pdfService', () => ({
|
||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||
}));
|
||
|
||
jest.mock('../../src/services/emailProcessor', () => ({
|
||
queueEmail: jest.fn(async () => {}),
|
||
}));
|
||
|
||
jest.mock('../../src/utils/logger', () => ({
|
||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||
}));
|
||
|
||
const invoiceService = require('../../src/services/invoiceService');
|
||
|
||
function resetChains() {
|
||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||
jest.clearAllMocks();
|
||
}
|
||
|
||
const activeCustomer = {
|
||
id: 5, is_active: 1, feature_bills: 1,
|
||
billing_cadence: 'per_event', preferred_language: 'de',
|
||
};
|
||
|
||
describe('createInvoice — happy path + totals', () => {
|
||
beforeEach(() => resetChains());
|
||
|
||
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||
|
||
const result = await invoiceService.createInvoice({
|
||
customerAccountId: 5,
|
||
vatRate: 8.1,
|
||
lineItems: [
|
||
// 2 × 100.00 = 200.00
|
||
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||
// 50.00 with 10% discount = 45.00
|
||
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||
],
|
||
}, 1);
|
||
|
||
expect(result.invoiceIds).toEqual([777]);
|
||
|
||
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||
invoice_number: 'R-2026-0042',
|
||
customer_account_id: 5,
|
||
currency: 'CHF',
|
||
status: 'scheduled',
|
||
net_amount_minor: 59500,
|
||
vat_rate: 8.1,
|
||
vat_amount_minor: 4820,
|
||
shipping_amount_minor: 0,
|
||
total_amount_minor: 64320,
|
||
installment_total: 1,
|
||
}));
|
||
// Exactly one sequence number claimed for a single-row create.
|
||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||
// Line items landed in invoice_line_items.
|
||
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||
});
|
||
|
||
it('409s on a deactivated customer before touching the sequence', async () => {
|
||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||
await expect(invoiceService.createInvoice({
|
||
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||
await expect(invoiceService.createInvoice({
|
||
customerAccountId: 5,
|
||
vatRate: 7.7,
|
||
lineItems: [
|
||
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||
],
|
||
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||
beforeEach(() => resetChains());
|
||
|
||
it('lists invoices with total + pagination echo', async () => {
|
||
pickChainFor('invoices')._selectResult = [
|
||
{ id: 1, invoice_number: 'R-2026-0001' },
|
||
{ id: 2, invoice_number: 'R-2026-0002' },
|
||
];
|
||
pickChainFor('invoices')._firstValue = { total: 7 };
|
||
|
||
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||
|
||
expect(result.rows).toHaveLength(2);
|
||
expect(result.total).toBe(7);
|
||
expect(result.page).toBe(2);
|
||
expect(result.pageSize).toBe(10);
|
||
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||
});
|
||
|
||
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||
pickChainFor('invoice_line_items as li')._selectResult = [
|
||
{ id: 30, position: 1, description: 'Shoot' },
|
||
];
|
||
pickChainFor('invoice_payment_log')._selectResult = [];
|
||
|
||
const result = await invoiceService.getInvoiceById(3);
|
||
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.payments).toEqual([]);
|
||
});
|
||
|
||
it('getInvoiceById returns null for an unknown id', async () => {
|
||
pickChainFor('invoices')._firstValue = undefined;
|
||
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||
beforeEach(() => resetChains());
|
||
|
||
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||
pickChainFor('invoices')._firstValue = {
|
||
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||
};
|
||
const result = await invoiceService.cancelInvoice(9, 1);
|
||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||
expect.objectContaining({ status: 'cancelled' })
|
||
);
|
||
});
|
||
|
||
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||
pickChainFor('invoices')._firstValue = {
|
||
id: 9, status: 'cancelled', kind: 'invoice',
|
||
};
|
||
await expect(invoiceService.cancelInvoice(9, 1))
|
||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||
});
|
||
|
||
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||
pickChainFor('invoices')._firstValue = {
|
||
id: 10, status: 'sent', kind: 'storno',
|
||
};
|
||
await expect(invoiceService.cancelInvoice(10, 1))
|
||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||
});
|
||
|
||
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||
pickChainFor('invoices')._firstValue = {
|
||
id: 11, status: 'sent', kind: 'invoice',
|
||
};
|
||
await expect(invoiceService.releaseForDelivery(11, 1))
|
||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||
});
|
||
});
|