Files
picpeak/backend/__tests__/services/invoiceService.smoke.test.js
T
Paul Nothaft d44ead41c5 test: add smoke tests for invoiceService, adminEvents routes, backupService
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.
2026-07-03 07:49:45 +02:00

260 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' });
});
});