Merge branch 'beta' into fix/email-normalization-574
Resolves a conflict with the CRM merge (#555) that landed on beta between when this branch was cut and now. Two conflict regions in backend/src/routes/adminCustomers.js: 1. **Require block** — both branches added new requires after customerAccountsService. Kept both: this branch's emailNormalization import AND beta's customerHoursService + invoiceService imports (the CRM merge added the hours-billing + invoice-creation paths to this router). 2. **Edit-customer validators** — both branches changed the same set of body() validators in the PUT /:id handler. This branch added the IDENTITY_PRESERVING_NORMALIZE_EMAIL options arg to normalizeEmail; beta changed every body() to optional({ nullable: true }) so passive-customer records that store nulls for missing profile fields don't reject on save. Kept both: the nullable pattern from beta + the email-normalization options from this branch. Preserved beta's explanatory comment about the nullable choice. Also patched one NEW normalizeEmail site the CRM merge introduced: - backend/src/routes/adminCustomers.js:231 — POST /admin/customers now exists (CRM-era customer-create endpoint). Same options arg applied. backend/src/routes/adminBusinessProfile.js has an isEmail() WITHOUT normalizeEmail() on the issuer email — intentional (no normalization means no risk of the Gmail dot-strip bug for that field), no change needed. All 18 normalizeEmail sites now pass IDENTITY_PRESERVING_NORMALIZE_EMAIL. 7/7 regression tests still pass. Lint clean on the merged file.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Schema-shape regression net for the CRM consolidated migration.
|
||||
*
|
||||
* Pins the table/column layout that the route + service layer expect
|
||||
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
|
||||
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
|
||||
* forward-reference deferral added in this PR), but it doesn't notice
|
||||
* if a future edit silently drops a column the service code reads —
|
||||
* SQLite would just return undefined and the broken behavior would
|
||||
* land on beta.
|
||||
*
|
||||
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
|
||||
* so a rename or removal there fails the test instead of silently
|
||||
* breaking the lineage card.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('CRM schema after core migrations', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('table layout', () => {
|
||||
const expectedTables = [
|
||||
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
|
||||
'events', 'document_sequences',
|
||||
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
|
||||
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
|
||||
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
|
||||
'customer_hour_entries',
|
||||
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
|
||||
'event_payment_plans',
|
||||
];
|
||||
|
||||
it.each(expectedTables)('has table %s', async (table) => {
|
||||
expect(await db.schema.hasTable(table)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deal_uuid lineage columns', () => {
|
||||
// Every document in one engagement shares a deal_uuid — the
|
||||
// lineage card joins on it. Drop the column anywhere in the chain
|
||||
// and the card silently returns partial data.
|
||||
it.each(['quotes', 'contracts', 'invoices'])(
|
||||
'%s has deal_uuid column',
|
||||
async (table) => {
|
||||
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// The back-pointer FKs were the source of the schema-drift bug
|
||||
// we fixed in this PR (forward references). Pin them.
|
||||
it('quotes has converted_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_quote_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storno discriminator columns', () => {
|
||||
// kind='storno' + cancels_invoice_id + negative totals are the
|
||||
// shape every aggregate filter relies on (feedback_storno_filter_
|
||||
// everywhere). Pin the columns so a rename doesn't silently break
|
||||
// every revenue report.
|
||||
it('invoices has kind discriminator', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
|
||||
});
|
||||
it('invoices has cancels_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
|
||||
});
|
||||
it('invoices has replaces_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event time columns (migration 137)', () => {
|
||||
// The admin calendar reads these to render timed vs. full-day
|
||||
// tiles. Per the feedback_migration_preserve_visuals rule, the
|
||||
// default has to be `is_full_day=true` so existing rows keep
|
||||
// their pre-migration visual.
|
||||
it('events has event_time_start', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
|
||||
});
|
||||
it('events has event_time_end', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
|
||||
});
|
||||
it('events has is_full_day', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seed paths', () => {
|
||||
it('admin + customer seed inserts cleanly', async () => {
|
||||
const { adminId, customerId } = await seedMinimal(db);
|
||||
expect(adminId).toBeTruthy();
|
||||
expect(customerId).toBeTruthy();
|
||||
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(admin.email).toBe('[email protected]');
|
||||
expect(customer.email).toBe('[email protected]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Negative line items (Rabatt / manual discount lines) are accepted
|
||||
* end-to-end as long as the resulting total stays ≥ 0. When the
|
||||
* discount would drive the total negative, the service rejects with
|
||||
* a clear, code-tagged error so the admin is steered to Storno for
|
||||
* credit-note workflows.
|
||||
*
|
||||
* Touches the actual createInvoice / createQuote service paths so a
|
||||
* future change to either computeTotals or the guard fires this test.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let invoiceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Quote-side coverage of the symmetric validator + guard is
|
||||
// deliberately omitted: createQuote's init path takes ~30 s under
|
||||
// this harness (something in pdfService / emailProcessor cold-
|
||||
// require), which would push the suite well past CI's per-test
|
||||
// budget. The shape of the guard is identical to the invoice one
|
||||
// covered below; a future change to extract the slow init or to
|
||||
// stub it for tests should re-enable a parallel quote test.
|
||||
|
||||
describe('invoices', () => {
|
||||
it('accepts a negative-price line and computes the net correctly', async () => {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
|
||||
expect(Array.isArray(invoiceIds)).toBe(true);
|
||||
expect(invoiceIds.length).toBe(1);
|
||||
|
||||
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
|
||||
expect(row.net_amount_minor).toBe(15000);
|
||||
expect(row.total_amount_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('rejects when the discount drives the total negative', async () => {
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId)).rejects.toMatchObject({
|
||||
code: 'INVOICE_TOTAL_NEGATIVE',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Boot-time email-template self-heal:
|
||||
* 1. Seeds the CRM / contract / event-reminder templates on an
|
||||
* install that's never had them before.
|
||||
* 2. Recovers email_queue rows that previously exhausted their
|
||||
* retries because their template was missing.
|
||||
*
|
||||
* The failure that triggered this fix (2026-05-27) had Ralf's beta
|
||||
* box failing every `quote_sent` / `invoice_sent` send for ~14h
|
||||
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
|
||||
* defined but never called. After 3 retries the rows sat in
|
||||
* status='pending' forever; nothing in the admin UI signalled the
|
||||
* problem. Both halves of that regression are covered here.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('email template self-heal at boot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
|
||||
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
|
||||
// 107_crm_consolidated documents the deliberate split (templates
|
||||
// are self-healed at runtime, not inserted by the migration).
|
||||
const before = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(before).toEqual([]);
|
||||
|
||||
// Seed a stuck queue row that mirrors what we found on Ralf's box:
|
||||
// quote_sent send attempted 3 times, each time failed because the
|
||||
// template didn't exist, queue processor gave up.
|
||||
const queueRowIds = await db('email_queue').insert({
|
||||
recipient_email: '[email protected]',
|
||||
email_type: 'quote_sent',
|
||||
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: "Email template 'quote_sent' not found",
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
|
||||
|
||||
// Also seed an UNRELATED stuck row (different template, NOT one
|
||||
// we're going to insert) to confirm the recovery is targeted —
|
||||
// it must not blanket-reset every retry-exhausted row.
|
||||
const unrelatedIds = await db('email_queue').insert({
|
||||
recipient_email: '[email protected]',
|
||||
email_type: 'some_other_template',
|
||||
email_data: JSON.stringify({}),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: 'SMTP timeout',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
|
||||
|
||||
// The seeders use module-level caches (`_seeded = true`). When
|
||||
// jest runs this test in isolation that cache starts fresh; in
|
||||
// the full suite no other test currently calls these seeders, so
|
||||
// the first call here also runs the real work. Reset the cache
|
||||
// defensively in case a future test changes that.
|
||||
jest.resetModules();
|
||||
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
|
||||
|
||||
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
|
||||
|
||||
// Templates landed.
|
||||
expect(result.seeded).toEqual(expect.arrayContaining([
|
||||
'quote_sent', 'invoice_sent', 'storno_issued',
|
||||
]));
|
||||
const after = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
|
||||
|
||||
// Stuck quote_sent row was recovered.
|
||||
expect(result.recovered).toBeGreaterThanOrEqual(1);
|
||||
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
|
||||
expect(recoveredRow.retry_count).toBe(0);
|
||||
expect(recoveredRow.error_message).toBeNull();
|
||||
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
|
||||
|
||||
// Unrelated stuck row was NOT touched.
|
||||
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
|
||||
expect(unrelatedRow.retry_count).toBe(3);
|
||||
expect(unrelatedRow.error_message).toBe('SMTP timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Test harness for CRM integration tests.
|
||||
*
|
||||
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
|
||||
* directly (bypassing knex's Migrator — its exclusive write lock
|
||||
* deadlocks 001_init's nested `initializeDatabase()` call), and
|
||||
* exposes a small helper for seeding the minimal row set that the
|
||||
* quote/contract/invoice services need to operate.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
*
|
||||
* beforeAll(async () => {
|
||||
* ({ db, cleanup } = await bootCrmDb());
|
||||
* ({ adminId, customerId } = await seedMinimal(db));
|
||||
* });
|
||||
* afterAll(async () => { await cleanup(); });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
async function runCoreMigrations(db) {
|
||||
await db.schema.createTable('migrations', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').unique().notNullable();
|
||||
t.timestamp('applied_at').defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
|
||||
const files = (await fs.promises.readdir(coreDir))
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
const mod = require(path.join(coreDir, f));
|
||||
if (typeof mod.up === 'function') {
|
||||
await mod.up(db);
|
||||
}
|
||||
await db('migrations').insert({ filename: f });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
|
||||
* Caller must invoke cleanup() in afterAll to release the SQLite file
|
||||
* and the temp directory.
|
||||
*/
|
||||
async function bootCrmDb() {
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
// No jest.resetModules() — every service the test later requires
|
||||
// must share THIS db instance. Two module copies on one SQLite file
|
||||
// each open their own knex pool and the SQLite write lock deadlocks
|
||||
// the second one acquiring a connection. Caller is responsible for
|
||||
// setting TEST_DATABASE_PATH before the first require of db.js
|
||||
// (which knexfile reads at module-init time); bootCrmDb only works
|
||||
// when invoked before any service import.
|
||||
const { db } = require('../../../src/database/db');
|
||||
|
||||
await runCoreMigrations(db);
|
||||
|
||||
return {
|
||||
db,
|
||||
tmpDir,
|
||||
cleanup: async () => {
|
||||
try { await db.destroy(); } catch (_) {}
|
||||
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimal row set that quote/contract/invoice services
|
||||
* dereference on creation: an admin user, an active customer, a
|
||||
* business_profile row, and the app_settings keys the services read.
|
||||
*
|
||||
* Returns the ids the caller will pass into service calls.
|
||||
*/
|
||||
async function seedMinimal(db) {
|
||||
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
|
||||
|
||||
const adminInsert = await db('admin_users').insert({
|
||||
username: 'tester', email: '[email protected]',
|
||||
password_hash: passwordHash, must_change_password: false,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||
|
||||
// business_profile is a singleton; the row is seeded by migration 107
|
||||
// for fresh installs. Defensive: insert if missing.
|
||||
const profile = await db('business_profile').first();
|
||||
if (!profile) {
|
||||
await db('business_profile').insert({
|
||||
legal_name: 'Test Studio',
|
||||
default_currency: 'CHF',
|
||||
default_locale: 'de',
|
||||
});
|
||||
}
|
||||
|
||||
const customerInsert = await db('customer_accounts').insert({
|
||||
email: '[email protected]',
|
||||
display_name: 'Test Customer',
|
||||
password_hash: passwordHash,
|
||||
preferred_language: 'de',
|
||||
is_active: 1,
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const customerId = customerInsert[0]?.id ?? customerInsert[0];
|
||||
|
||||
return { adminId, customerId };
|
||||
}
|
||||
|
||||
module.exports = { bootCrmDb, seedMinimal };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Unit tests for the recipient resolver that routes invoice / Storno /
|
||||
* reminder emails to a bookkeeper address when one is configured,
|
||||
* while keeping the decision-maker (primary email) on CC.
|
||||
*
|
||||
* Pure helper, no DB, no side effects.
|
||||
*/
|
||||
|
||||
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
|
||||
|
||||
describe('resolveBillingRecipients', () => {
|
||||
it('routes to the primary email when no billing_email is set', () => {
|
||||
expect(resolveBillingRecipients({ email: '[email protected]' }, null))
|
||||
.toEqual({ to: '[email protected]', cc: undefined });
|
||||
});
|
||||
|
||||
it('routes to billing_email and CCs the primary when both are set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
billing_email: '[email protected]',
|
||||
}, null)).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: ['[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('folds the per-document cc_pdf_email into the CC list', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
billing_email: '[email protected]',
|
||||
}, '[email protected]')).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: ['[email protected]', '[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses cc_pdf_email alone when there is no billing_email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
}, '[email protected]')).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: ['[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not CC the primary onto itself when billing_email equals email', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
billing_email: '[email protected]',
|
||||
}, null)).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('is case-insensitive when deduping addresses', () => {
|
||||
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
|
||||
// practice every mail server treats them as insensitive — and the
|
||||
// admin entering "[email protected]" in one field and
|
||||
// "[email protected]" in another should not produce two copies.
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
billing_email: '[email protected]',
|
||||
}, '[email protected]')).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: ['[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace around the addresses', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: ' [email protected] ',
|
||||
billing_email: ' [email protected]\n',
|
||||
}, '\[email protected] ')).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: ['[email protected]', '[email protected]'],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty-string billing_email as not set', () => {
|
||||
expect(resolveBillingRecipients({
|
||||
email: '[email protected]',
|
||||
billing_email: '',
|
||||
}, null)).toEqual({
|
||||
to: '[email protected]',
|
||||
cc: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty To when neither email nor billing_email is set', () => {
|
||||
// Caller is responsible for surfacing this — emailProcessor's own
|
||||
// validation will reject the empty recipient. The helper just
|
||||
// refuses to crash.
|
||||
expect(resolveBillingRecipients({}, null))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
|
||||
it('tolerates a null customer without throwing', () => {
|
||||
// Per-doc cc alone is never promoted to To: — it stays
|
||||
// supplemental. A missing customer is a caller bug; we just refuse
|
||||
// to crash and let emailProcessor reject the empty recipient.
|
||||
expect(resolveBillingRecipients(null, '[email protected]'))
|
||||
.toEqual({ to: '', cc: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in contractService (migration 130).
|
||||
*
|
||||
* The DB-bound CRUD paths (createContract / sendContract /
|
||||
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
|
||||
* manual QA via the admin + public routes. This file covers the
|
||||
* deterministic helpers so regressions in placeholder substitution or
|
||||
* section ordering surface before they leak into a rendered contract.
|
||||
*
|
||||
* The service pulls in DB-bound peers (businessProfileService,
|
||||
* pdfService, emailProcessor) at the top level. We stub the DB layer
|
||||
* + the side-effect peers so the require chain doesn't try to connect
|
||||
* to anything; the helpers under test are pure.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderContractToBuffer: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
|
||||
|
||||
describe('renderTemplatedBody', () => {
|
||||
it('substitutes simple {{var}} placeholders', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Hello {{name}}, due in {{net_days}} days.',
|
||||
{ name: 'Alice', net_days: 30 },
|
||||
)).toBe('Hello Alice, due in 30 days.');
|
||||
});
|
||||
|
||||
it('preserves unknown placeholders literally so admins notice missing fields', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Bill from {{issuer}} to {{customer_name}}',
|
||||
{ issuer: 'PicPeak GmbH' },
|
||||
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
|
||||
});
|
||||
|
||||
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
|
||||
{ has_skonto: true, pct: 2 },
|
||||
)).toBe('Skonto: 2 % on early payment');
|
||||
});
|
||||
|
||||
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
|
||||
{ net_days: 30, has_skonto: false, pct: 2 },
|
||||
)).toBe('Net 30 d.');
|
||||
});
|
||||
|
||||
it('treats missing variables in {{#if}} as falsy', () => {
|
||||
expect(renderTemplatedBody(
|
||||
'A{{#if missing}}B{{/if}}C',
|
||||
{ unrelated: 'foo' },
|
||||
)).toBe('AC');
|
||||
});
|
||||
|
||||
it('handles empty strings and missing variables map gracefully', () => {
|
||||
expect(renderTemplatedBody('', { x: 1 })).toBe('');
|
||||
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
|
||||
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('passes through non-string input unchanged', () => {
|
||||
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
|
||||
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('substitutes numeric and falsy variable values as strings', () => {
|
||||
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
|
||||
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTIONS_ORDER', () => {
|
||||
it('matches the canonical six-section order locked in the spec', () => {
|
||||
expect(SECTIONS_ORDER).toEqual([
|
||||
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
|
||||
const blocksService = require('../../src/services/contractBlocksService');
|
||||
expect([...SECTIONS_ORDER].sort()).toEqual(
|
||||
[...blocksService.ALLOWED_SECTIONS].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tests for the passive-customer surface:
|
||||
*
|
||||
* - createDirect inserts a customer with password_hash=null,
|
||||
* queueEmail is never called, race-guard rejects duplicates
|
||||
* - createInvitation allows passing through when the existing
|
||||
* customer is passive (promotion path); still rejects when the
|
||||
* existing customer is active (real duplicate)
|
||||
* - acceptInvitation upserts into an existing passive customer
|
||||
* row (preserving id) when one exists; inserts a fresh row
|
||||
* otherwise; still rejects when the existing customer is active
|
||||
*
|
||||
* Pure unit tests — db is mocked via a thenable chain so we can
|
||||
* inspect every insert / update payload without spinning up SQLite.
|
||||
*/
|
||||
|
||||
// ----- mock db chain --------------------------------------------------
|
||||
//
|
||||
// We need fine-grained control over which row each table-name returns
|
||||
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
|
||||
// what `.update(...)` resolves to. The chain is a thenable proxy that
|
||||
// terminates on the call we care about.
|
||||
|
||||
const tableSeeds = {}; // table → first-row return value
|
||||
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
|
||||
const updateCalls = []; // [{ table, where, updates }]
|
||||
let nextInsertId = 1000;
|
||||
|
||||
function resetMockDb() {
|
||||
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
|
||||
for (const k of Object.keys(insertResults)) delete insertResults[k];
|
||||
updateCalls.length = 0;
|
||||
nextInsertId = 1000;
|
||||
}
|
||||
|
||||
function makeChain(tableName) {
|
||||
const chain = {
|
||||
_whereClauses: [],
|
||||
where(...args) { this._whereClauses.push(args); return this; },
|
||||
whereNull() { return this; },
|
||||
whereNot() { return this; },
|
||||
andWhere() { return this; },
|
||||
orderBy() { return this; },
|
||||
leftJoin() { return this; },
|
||||
groupBy() { return this; },
|
||||
select(...args) {
|
||||
// listCustomers / search → return seeded array
|
||||
const seeded = tableSeeds[`${tableName}__select`];
|
||||
return Promise.resolve(seeded || []);
|
||||
},
|
||||
first() {
|
||||
const seeded = tableSeeds[tableName];
|
||||
return Promise.resolve(seeded);
|
||||
},
|
||||
insert(payload) {
|
||||
const id = nextInsertId++;
|
||||
insertResults[tableName] = insertResults[tableName] || [];
|
||||
insertResults[tableName].push({ ...payload, id });
|
||||
const result = { id };
|
||||
return {
|
||||
returning() { return Promise.resolve([result]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
update(updates) {
|
||||
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
|
||||
return Promise.resolve(1);
|
||||
},
|
||||
del() { return Promise.resolve(1); },
|
||||
raw() { return this; },
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
|
||||
mockDbFn.raw = jest.fn();
|
||||
mockDbFn.transaction = async (cb) => cb(mockDbFn);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const mockQueueEmail = jest.fn(async () => {});
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: mockQueueEmail,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: { default_locale: 'de' },
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const customerAccountsService = require('../../src/services/customerAccountsService');
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockDb();
|
||||
mockQueueEmail.mockClear();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createDirect
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createDirect', () => {
|
||||
it('inserts a customer with password_hash=null, is_active=true', async () => {
|
||||
tableSeeds.customer_accounts = undefined; // no duplicate
|
||||
const result = await customerAccountsService.createDirect({
|
||||
email: '[email protected]',
|
||||
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
|
||||
createdByAdminId: 5,
|
||||
});
|
||||
expect(result.id).toBeDefined();
|
||||
const inserted = insertResults.customer_accounts[0];
|
||||
expect(inserted.email).toBe('[email protected]');
|
||||
expect(inserted.password_hash).toBeNull();
|
||||
expect(inserted.created_by_admin_id).toBe(5);
|
||||
expect(inserted.first_name).toBe('Anna');
|
||||
expect(inserted.company_name).toBe('ACME GmbH');
|
||||
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
|
||||
expect([true, 1, '1']).toContain(inserted.is_active);
|
||||
});
|
||||
|
||||
it('defaults preferred_language from the business profile', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: '[email protected]',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
|
||||
});
|
||||
|
||||
it('honours preferred_language when the admin pre-fills it', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: '[email protected]',
|
||||
prefill: { preferred_language: 'fr' },
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
|
||||
});
|
||||
|
||||
it('rejects when a customer with the email already exists', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: 'whatever' };
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: '[email protected]',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await expect(customerAccountsService.createDirect({
|
||||
email: '',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
})).rejects.toThrow(/Email is required/);
|
||||
});
|
||||
|
||||
it('NEVER queues an invitation email (regression guard)', async () => {
|
||||
tableSeeds.customer_accounts = undefined;
|
||||
await customerAccountsService.createDirect({
|
||||
email: '[email protected]',
|
||||
prefill: {},
|
||||
createdByAdminId: 1,
|
||||
});
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// createInvitation passive-allowance behaviour
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe('createInvitation — duplicate-email guard', () => {
|
||||
it('still rejects when the existing customer has a password (real duplicate)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 1, email: '[email protected]', password_hash: 'hash' };
|
||||
await expect(customerAccountsService.createInvitation({
|
||||
email: '[email protected]',
|
||||
invitedById: 5,
|
||||
prefill: null,
|
||||
})).rejects.toThrow(/already exists/);
|
||||
expect(mockQueueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
|
||||
tableSeeds.customer_accounts = { id: 7, email: '[email protected]', password_hash: null };
|
||||
// no pending invitation
|
||||
// The chain returns `tableSeeds.customer_invitations` for .first()
|
||||
// and we haven't seeded one, so it's undefined → allowed through.
|
||||
const out = await customerAccountsService.createInvitation({
|
||||
email: '[email protected]',
|
||||
invitedById: 9,
|
||||
prefill: { first_name: 'Anna' },
|
||||
});
|
||||
expect(out.id).toBeDefined();
|
||||
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers in customerHoursService (migration
|
||||
* 129). The CRUD paths themselves are exercised end-to-end via the
|
||||
* admin/customers routes during manual QA; this file covers the
|
||||
* deterministic logic so regressions in the rate / duration / lock
|
||||
* resolution show up before they hit a real invoice.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
|
||||
|
||||
// The service imports invoiceService which pulls in the DB. We don't
|
||||
// need either for the pure helpers — stub the DB layer so the
|
||||
// require chain doesn't try to connect to anything.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: jest.fn(),
|
||||
logActivity: jest.fn(),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/invoiceService', () => ({}));
|
||||
|
||||
const { _internal } = require(servicePath);
|
||||
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
|
||||
|
||||
describe('computeDurationMinutes', () => {
|
||||
it('returns minute count for a basic window', () => {
|
||||
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
|
||||
});
|
||||
|
||||
it('handles single-minute precision', () => {
|
||||
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
|
||||
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
|
||||
});
|
||||
|
||||
it('rejects malformed input', () => {
|
||||
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
|
||||
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
|
||||
});
|
||||
|
||||
it('rejects zero or negative duration', () => {
|
||||
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
|
||||
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEffectiveRate', () => {
|
||||
it('prefers the per-entry override when set', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 20000 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(20000);
|
||||
});
|
||||
|
||||
it('falls back to the customer default when no override', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('throws when both override and customer rate are unset', () => {
|
||||
expect(() => resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
)).toThrow(/No hourly rate/);
|
||||
});
|
||||
|
||||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||||
// tracking. Must NOT fall through to the customer default.
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: 0 },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEntryLocked', () => {
|
||||
it('unbilled entry → not locked', () => {
|
||||
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('monthly draft → not locked (still accumulating)', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('standalone draft with no send time → not locked', () => {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('future-scheduled draft → not locked', () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('sent / paid / overdue / cancelled → locked', () => {
|
||||
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
|
||||
expect(isEntryLocked(
|
||||
{ invoice_id: 42 },
|
||||
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
|
||||
)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('entry references a deleted invoice (null) → treat as unbilled', () => {
|
||||
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLineItemFromEntry', () => {
|
||||
const baseEntry = {
|
||||
entry_date: '2026-05-20',
|
||||
start_time: '09:00',
|
||||
end_time: '11:30',
|
||||
duration_minutes: 150,
|
||||
description: 'Editing wedding photos',
|
||||
};
|
||||
|
||||
it('formats the description per spec', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h): Editing wedding photos');
|
||||
});
|
||||
|
||||
it('omits the colon when no description', () => {
|
||||
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
|
||||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)');
|
||||
});
|
||||
|
||||
it('quantity is decimal hours with 2 places', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||||
expect(li.quantity).toBeCloseTo(2.5, 5);
|
||||
});
|
||||
|
||||
it('line_total rounds correctly for non-clean durations', () => {
|
||||
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
|
||||
const li = buildLineItemFromEntry(
|
||||
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
|
||||
10000,
|
||||
);
|
||||
expect(li.line_total_minor).toBe(2500);
|
||||
});
|
||||
|
||||
it('zero-rate line items produce a zero total without exploding', () => {
|
||||
const li = buildLineItemFromEntry(baseEntry, 0);
|
||||
expect(li.line_total_minor).toBe(0);
|
||||
expect(li.unit_price_minor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
|
||||
* gates the migration-137 calendar time columns on events.
|
||||
*
|
||||
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
|
||||
* helper and write through hasColumnCached guards; those are
|
||||
* exercised in manual QA. This file pins the contract so a future
|
||||
* tweak to the validation rules doesn't silently break it.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
|
||||
|
||||
// Stub every DB-bound peer so the require chain doesn't try to open
|
||||
// a knex connection. The helper under test is pure.
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
|
||||
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
|
||||
|
||||
const { normaliseEventTimeTriple } = require(servicePath);
|
||||
|
||||
describe('normaliseEventTimeTriple', () => {
|
||||
it('defaults to full-day when is_full_day is undefined', () => {
|
||||
expect(normaliseEventTimeTriple({})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forces times to null when is_full_day is true even if times are supplied', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: true,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid timed range when is_full_day is false', () => {
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
})).toEqual({
|
||||
event_time_start: '09:30',
|
||||
event_time_end: '17:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when is_full_day is false and start is missing/malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '25:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '9:00',
|
||||
event_time_end: '12:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is missing or malformed', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
})).toThrow(/HH:MM/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '12:99',
|
||||
})).toThrow(/HH:MM/);
|
||||
});
|
||||
|
||||
it('throws when end is at or before start', () => {
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '10:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
expect(() => normaliseEventTimeTriple({
|
||||
is_full_day: false,
|
||||
event_time_start: '15:00',
|
||||
event_time_end: '10:00',
|
||||
})).toThrow(/after/);
|
||||
});
|
||||
|
||||
it('parses string boolean flag', () => {
|
||||
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
|
||||
// the helper consumes them transparently.
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: 'false',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
is_full_day: false,
|
||||
});
|
||||
expect(normaliseEventTimeTriple({
|
||||
is_full_day: '1',
|
||||
event_time_start: '08:00',
|
||||
event_time_end: '09:00',
|
||||
})).toEqual({
|
||||
event_time_start: null,
|
||||
event_time_end: null,
|
||||
is_full_day: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in invoiceService —
|
||||
* the shared helpers come from quoteService._internal (validated in
|
||||
* quoteService.hierarchy.test.js), so we focus here on the
|
||||
* invoice-specific seams:
|
||||
*
|
||||
* - quote → invoice cloner preserves parent_position + details_text
|
||||
* across the conversion
|
||||
* - the cloner's installment "adjustment" line only reconciles
|
||||
* against TOP-LEVEL cloned items (sub-items don't contribute to
|
||||
* net so they can't appear in the sum)
|
||||
*
|
||||
* Pure helper, no DB.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
|
||||
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
|
||||
|
||||
describe('quote → invoice cloner shape', () => {
|
||||
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
|
||||
// take source quote line items (with parent_position) and produce the
|
||||
// `cloned` array that's passed into insertLineItemsHierarchical.
|
||||
function modelCloner(sourceLines) {
|
||||
return sourceLines.map((li) => ({
|
||||
position: parseInt(li.position, 10),
|
||||
quantity: Number(li.quantity || 1),
|
||||
description: li.description,
|
||||
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
|
||||
discount_percent: Number(li.discount_percent || 0),
|
||||
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
|
||||
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
|
||||
details_text: li.details_text || null,
|
||||
}));
|
||||
}
|
||||
|
||||
it('preserves parent_position so the hierarchy carries across conversion', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].parent_position).toBeNull();
|
||||
expect(cloned[1].parent_position).toBe(1);
|
||||
expect(cloned[2].parent_position).toBe(1);
|
||||
// The cloned shape passes hierarchy validation — same positions
|
||||
// means the same parent links work without any remap.
|
||||
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves details_text verbatim', () => {
|
||||
const source = [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
|
||||
details_text: 'Includes online gallery + 100 high-res downloads.' },
|
||||
];
|
||||
const cloned = modelCloner(source);
|
||||
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
|
||||
});
|
||||
|
||||
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
|
||||
// Recreate the inner math from scheduleInvoicesForEvent: sum
|
||||
// only line_total_minor where parent_position is null. Sub-items
|
||||
// would otherwise double-count and skew the adjustment.
|
||||
//
|
||||
// Note: the cloner stores raw line_total_minor on each row from
|
||||
// the source quote. By the time this sum runs, the parent's
|
||||
// line_total_minor has already been resolved upstream (via
|
||||
// computeTotals on the quote at save time) — so iterating
|
||||
// top-level only sums the resolved parent totals + standalone
|
||||
// top-level items. Sub-items never contribute here regardless of
|
||||
// whether their parent's total was auto-resolved or not.
|
||||
const cloned = modelCloner([
|
||||
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
|
||||
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
|
||||
// Sub-items €150 + €200 + €100 — shown for transparency, must
|
||||
// NOT enter the reconciliation sum.
|
||||
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
|
||||
// Another top-level €100
|
||||
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
const clonedSum = cloned
|
||||
.filter((x) => x.parent_position == null)
|
||||
.reduce((s, x) => s + x.line_total_minor, 0);
|
||||
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
|
||||
expect(clonedSum).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical for invoices', () => {
|
||||
function makeTrxMock() {
|
||||
let nextId = 200;
|
||||
const inserts = [];
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); },
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
|
||||
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
]);
|
||||
expect(inserts).toHaveLength(2);
|
||||
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
|
||||
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
|
||||
// Parent inserted first, sub-item second with parent_line_item_id
|
||||
// matching the parent's synthesised id.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
|
||||
*
|
||||
* Validation tests run against the pure validator directly. Orchestration
|
||||
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
|
||||
* — chains are queued per table and assertions probe insert/update/delete
|
||||
* 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));
|
||||
|
||||
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', () => ({
|
||||
claimNextSequence: jest.fn(async () => 42),
|
||||
}));
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
describe('validateInstallmentPlanInput', () => {
|
||||
const { validateInstallmentPlanInput } = invoiceService;
|
||||
|
||||
it('throws on empty array', () => {
|
||||
expect(() => validateInstallmentPlanInput([]))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on non-array', () => {
|
||||
expect(() => validateInstallmentPlanInput(null))
|
||||
.toThrow(/non-empty array/);
|
||||
});
|
||||
|
||||
it('throws on out-of-range percent', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
|
||||
])).toThrow(/percent must be between 0 and 100/);
|
||||
});
|
||||
|
||||
it('throws on unknown trigger', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
|
||||
])).toThrow(/invalid trigger/);
|
||||
});
|
||||
|
||||
it('throws when percents do not sum to 100', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -7 },
|
||||
])).toThrow(/must sum to 100/);
|
||||
});
|
||||
|
||||
it('accepts a valid three-row plan with mixed triggers', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
|
||||
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
|
||||
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('tolerates 0.001 rounding drift in the sum', () => {
|
||||
expect(() => validateInstallmentPlanInput([
|
||||
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
|
||||
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const goodPlan = [
|
||||
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
];
|
||||
|
||||
it('rejects when dealUuid is missing', async () => {
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the deal has no invoices', async () => {
|
||||
pickChainFor('invoices')._selectResult = [];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
|
||||
});
|
||||
|
||||
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
|
||||
});
|
||||
|
||||
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
|
||||
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
|
||||
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
|
||||
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
|
||||
});
|
||||
|
||||
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
|
||||
const badPlan = [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -7 },
|
||||
];
|
||||
await expect(invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
|
||||
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstallmentPlan — reshape (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
const sibling = (overrides) => ({
|
||||
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
|
||||
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
shipping_amount_minor: 0, vat_rate: 7.7,
|
||||
customer_account_id: 5, source_quote_id: null, event_id: null,
|
||||
event_name: 'Wedding', event_date: '2026-08-15',
|
||||
language: 'de', currency: 'CHF',
|
||||
issue_date: '2026-05-25', due_date: '2026-06-24',
|
||||
cc_pdf_email: null,
|
||||
payment_net_days_template_id: null, payment_timing_template_id: null,
|
||||
payment_term_snapshot: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2, 3]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
// Sequence helper never touched on a same-count reshape.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
installment_total: 2 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('invoices')._insertResult = [{ id: 99 }];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
|
||||
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created.length).toBe(1);
|
||||
expect(result.deleted).toEqual([]);
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shrinks 3→2 by deleting the third row + its line items', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
|
||||
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
|
||||
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
|
||||
];
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.updateInstallmentPlan({
|
||||
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
|
||||
installments: [
|
||||
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
|
||||
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.kept).toEqual([1, 2]);
|
||||
expect(result.created).toEqual([]);
|
||||
expect(result.deleted).toEqual([3]);
|
||||
// Line items + invoice rows deleted on the trimmed sibling.
|
||||
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
|
||||
expect(pickChainFor('invoices').del).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Tests for invoiceService lock + state-transition guards.
|
||||
*
|
||||
* Focuses on the rules that protect tax/audit integrity:
|
||||
* - reissueInvoice refuses to act on `scheduled` (use Edit)
|
||||
* - reissueInvoice cancels + clones any other status
|
||||
* - releaseForDelivery refuses to act on non-pending_delivery
|
||||
* - recordPaymentCheckAction refuses already-used / expired tokens
|
||||
*
|
||||
* db is deep-mocked so the tests are deterministic and fast.
|
||||
*/
|
||||
|
||||
// Mock db chain: each table call returns a builder whose methods
|
||||
// chain (return `this`) until a terminal method (.first / .update /
|
||||
// .insert / .returning) resolves with the queued value.
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
_allRows: [],
|
||||
// knex chains are thenable — awaiting them runs the query and
|
||||
// resolves with the row set. We mirror that so callers can
|
||||
// `await trx('t').where(...).orderBy(...)` and get an array.
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereNotIn: 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 is both chainable (`.select('col').first()`) and awaitable
|
||||
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
|
||||
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));
|
||||
// db.transaction(cb) runs the callback with a "trx" — for our
|
||||
// purposes the same chain factory works as trx.
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
|
||||
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/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];
|
||||
}
|
||||
|
||||
describe('invoiceService.reissueInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
|
||||
await expect(invoiceService.reissueInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('throws when the source invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.reissueInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('cancels the original and creates a new row when status is sent', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'sent', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
shipping_amount_minor: 0, cc_pdf_email: null,
|
||||
business_bank_account_id: null, qr_format: null,
|
||||
payment_term_template_id: null, event_id: null,
|
||||
source_quote_id: null,
|
||||
};
|
||||
pickChainFor('customer_accounts')._firstValue = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const result = await invoiceService.reissueInvoice(1, 42);
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.replaces).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.createStorno', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the source invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.createStorno(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
|
||||
});
|
||||
|
||||
it('rejects when the source is already cancelled (no double-Storno)', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects when asked to Storno a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.createStorno(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('inserts a Storno row and flips the original on a sent invoice', async () => {
|
||||
// Original is `sent`, no line items, no event.
|
||||
const invoicesChain = pickChainFor('invoices');
|
||||
invoicesChain._firstValue = {
|
||||
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de', vat_rate: 7.7,
|
||||
net_amount_minor: 30000, vat_amount_minor: 2310,
|
||||
total_amount_minor: 32310, shipping_amount_minor: 0,
|
||||
cc_pdf_email: null, event_id: null,
|
||||
};
|
||||
pickChainFor('invoice_line_items')._selectResult = [];
|
||||
pickChainFor('app_settings')._firstValue = null;
|
||||
// document_sequences row used by claimNextSequence.
|
||||
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
|
||||
|
||||
const stornoId = await invoiceService.createStorno(1, 42);
|
||||
expect(stornoId).toBeDefined();
|
||||
|
||||
// The mock chain's .update() is called twice on `invoices`:
|
||||
// 1) `.insert(...).returning('id')` for the Storno row
|
||||
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
|
||||
// We just verify the helpers were exercised on the right table.
|
||||
expect(invoicesChain.insert).toHaveBeenCalled();
|
||||
expect(invoicesChain.update).toHaveBeenCalled();
|
||||
// The Storno insert payload should carry kind='storno' and
|
||||
// negated row-level totals. Inspect the first insert call's
|
||||
// payload to confirm.
|
||||
const insertedRow = invoicesChain.insert.mock.calls[0][0];
|
||||
expect(insertedRow.kind).toBe('storno');
|
||||
expect(insertedRow.net_amount_minor).toBe(-30000);
|
||||
expect(insertedRow.vat_amount_minor).toBe(-2310);
|
||||
expect(insertedRow.total_amount_minor).toBe(-32310);
|
||||
expect(insertedRow.cancels_invoice_id).toBe(1);
|
||||
expect(insertedRow.status).toBe('scheduled');
|
||||
// No payment instrument on a Storno.
|
||||
expect(insertedRow.business_bank_account_id).toBeNull();
|
||||
expect(insertedRow.qr_format).toBeNull();
|
||||
expect(insertedRow.payment_term_template_id).toBeNull();
|
||||
// Storni have no real payment due, but the schema's NOT NULL
|
||||
// constraint on due_date forces a value — we mirror issue_date.
|
||||
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.cancelInvoice', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects when the invoice does not exist (404)', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.cancelInvoice(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
|
||||
await expect(invoiceService.cancelInvoice(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
|
||||
const result = await invoiceService.cancelInvoice(1, 42);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.releaseForDelivery', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('refuses when status is not pending_delivery', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
|
||||
await expect(invoiceService.releaseForDelivery(1, 42))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
|
||||
it('404s when the invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
await expect(invoiceService.releaseForDelivery(999, 42))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.recordPaymentCheckAction', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('rejects invalid actions', async () => {
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'abc', action: 'foo',
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('404s when the token is not on file', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: new Date(),
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
|
||||
});
|
||||
|
||||
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() - 86400000),
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'unpaid',
|
||||
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
|
||||
});
|
||||
|
||||
it('rejects partial with amount <= 0', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects partial with amount > outstanding', async () => {
|
||||
pickChainFor('invoice_payment_check_tokens')._firstValue = {
|
||||
id: 1, used_at: null,
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
};
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
|
||||
};
|
||||
await expect(invoiceService.recordPaymentCheckAction({
|
||||
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
|
||||
})).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('invoiceService.queuePaymentCheckEmail', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('skips when invoice does not exist', async () => {
|
||||
pickChainFor('invoices')._firstValue = null;
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'not_found' });
|
||||
});
|
||||
|
||||
it('skips when status is not sent/overdue', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'paid',
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res.sent).toBe(false);
|
||||
expect(res.reason).toMatch(/wrong_status_paid/);
|
||||
});
|
||||
|
||||
it('respects the 24h throttle', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
};
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1);
|
||||
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
|
||||
});
|
||||
|
||||
it('bypasses the throttle when skipThrottle=true', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 1, status: 'overdue',
|
||||
customer_account_id: 5,
|
||||
created_by_admin_id: 42,
|
||||
total_amount_minor: 10000,
|
||||
currency: 'CHF',
|
||||
language: 'de',
|
||||
reminder_level: 0,
|
||||
due_date: '2026-05-01',
|
||||
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
|
||||
event_id: null,
|
||||
};
|
||||
pickChainFor('admin_users')._firstValue = { id: 42, email: '[email protected]', username: 'admin' };
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
pickChainFor('customer_accounts')._firstValue = { id: 5, email: '[email protected]', display_name: 'Test' };
|
||||
|
||||
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
|
||||
expect(res.sent).toBe(true);
|
||||
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Tests for the createBaseDocument + getPageMetrics helpers — the
|
||||
* shared PDF factory used by quote/invoice rendering AND by the
|
||||
* upcoming tax-report renderer. These verify orientation handling
|
||||
* and font defaults without touching DB or filesystem.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
|
||||
describe('getPageMetrics', () => {
|
||||
it('returns portrait A4 metrics by default', () => {
|
||||
const p = pdfService.getPageMetrics();
|
||||
expect(p.width).toBeCloseTo(595.28, 1);
|
||||
expect(p.height).toBeCloseTo(841.89, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(515.28, 1);
|
||||
});
|
||||
|
||||
it('returns portrait when orientation is "portrait"', () => {
|
||||
const p = pdfService.getPageMetrics('portrait');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
|
||||
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
|
||||
const p = pdfService.getPageMetrics('landscape');
|
||||
expect(p.width).toBeCloseTo(841.89, 1);
|
||||
expect(p.height).toBeCloseTo(595.28, 1);
|
||||
expect(p.contentWidth).toBeCloseTo(761.89, 1);
|
||||
expect(p.width).toBeGreaterThan(p.height);
|
||||
});
|
||||
|
||||
it('ignores unknown orientation values (falls back to portrait)', () => {
|
||||
const p = pdfService.getPageMetrics('upside-down');
|
||||
expect(p.width).toBeLessThan(p.height);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBaseDocument', () => {
|
||||
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
|
||||
const { doc, page, fonts } = pdfService.createBaseDocument();
|
||||
expect(doc).toBeDefined();
|
||||
expect(typeof doc.on).toBe('function');
|
||||
expect(typeof doc.font).toBe('function');
|
||||
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
|
||||
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
|
||||
});
|
||||
|
||||
it('produces a landscape document when orientation is "landscape"', () => {
|
||||
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
expect(page.width).toBeGreaterThan(page.height);
|
||||
// PDFKit stores the active page dims on doc.page.
|
||||
expect(doc.page.width).toBeCloseTo(841.89, 1);
|
||||
expect(doc.page.height).toBeCloseTo(595.28, 1);
|
||||
});
|
||||
|
||||
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
|
||||
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
const ended = new Promise((resolve) => doc.on('end', resolve));
|
||||
doc.text('hello', 40, 40);
|
||||
doc.end();
|
||||
await ended;
|
||||
const buf = Buffer.concat(chunks);
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: null },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when the custom TTF path does not exist', () => {
|
||||
// No exception, no logger.error blow-up — just silent fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('registers a bundled font family when pdfFontFamily is set', () => {
|
||||
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
|
||||
// backend/assets/fonts/Inter/, so the resolver should pick 400
|
||||
// for body and 700 for bold.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'Inter' },
|
||||
});
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: 'NotARealFamily' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('strips path-traversal characters from pdfFontFamily', () => {
|
||||
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
|
||||
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: { pdfFontFamily: '../../etc/passwd' },
|
||||
});
|
||||
expect(fonts.body).toBe('Helvetica');
|
||||
expect(fonts.bold).toBe('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
|
||||
// The explicit upload is the priority-1 override. When the upload
|
||||
// path is unusable (file missing) the family is consulted next.
|
||||
// Here we set BOTH to invalid values and confirm Helvetica fallback
|
||||
// — what matters is that the family DIDN'T get registered while a
|
||||
// (failed) explicit path was being evaluated.
|
||||
const { fonts } = pdfService.createBaseDocument({
|
||||
issuer: {
|
||||
pdfFontTtfPath: '/nonexistent/path/font.ttf',
|
||||
pdfFontFamily: 'Inter',
|
||||
},
|
||||
});
|
||||
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
|
||||
// registers successfully. crm-body / crm-bold confirm a custom
|
||||
// font won.
|
||||
expect(fonts.body).toBe('crm-body');
|
||||
expect(fonts.bold).toBe('crm-bold');
|
||||
});
|
||||
|
||||
it('forwards PDF info metadata (Title, Author) to the document', () => {
|
||||
const { doc } = pdfService.createBaseDocument({
|
||||
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
|
||||
});
|
||||
// PDFKit copies these onto doc.info during construction.
|
||||
expect(doc.info.Title).toBe('Tax Report 2026');
|
||||
expect(doc.info.Author).toBe('picpeak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported letterhead helper', () => {
|
||||
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
|
||||
expect(typeof pdfService.drawIssuerBlock).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// Storno rendering — smoke-tests that exercise the kind='storno'
|
||||
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
|
||||
// directly for German strings because PDFKit Flate-compresses
|
||||
// content streams, but we CAN verify the renderer:
|
||||
// - completes without throwing on a Storno-shaped context,
|
||||
// - produces a valid %PDF magic header,
|
||||
// - produces a SMALLER document than its invoice counterpart
|
||||
// (no payment block, no QR slip → fewer bytes), proving the
|
||||
// suppression branches actually fire.
|
||||
//
|
||||
// Visual correctness (title swap, reference line, signed totals) is
|
||||
// validated by manual review of a real Storno PDF; the renderer's
|
||||
// branch logic is unit-tested in service tests where the inputs
|
||||
// can be asserted directly.
|
||||
describe('renderInvoiceToBuffer — Storno branch', () => {
|
||||
function buildContext(overrides = {}) {
|
||||
return {
|
||||
locale: 'de',
|
||||
currency: 'CHF',
|
||||
issuer: { companyName: 'AcmeCo' },
|
||||
recipient: {
|
||||
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||
city: 'Bern', postalCode: '3000',
|
||||
},
|
||||
lineItems: [{
|
||||
quantity: 1, description: 'Photo session',
|
||||
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}],
|
||||
totals: {
|
||||
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 32310,
|
||||
},
|
||||
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
// Bank + payment term are part of the baseline invoice so the
|
||||
// payment block renders a real IBAN + Zahlungsbedingungen
|
||||
// section. The Storno branch suppresses this entirely, which
|
||||
// produces a visible byte-size delta.
|
||||
bank: {
|
||||
accountHolder: 'AcmeCo',
|
||||
iban: 'CH9300762011623852957',
|
||||
bic: 'POFICHBE',
|
||||
currency: 'CHF',
|
||||
},
|
||||
qrFormat: 'none',
|
||||
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
|
||||
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
|
||||
// Baseline: normal invoice with a payment block.
|
||||
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
|
||||
// Storno: same context but kind='storno' → payment block + QR
|
||||
// both suppressed. Payment block alone is ~80pt tall in the
|
||||
// PDF; its absence is reliably detectable as a byte-size delta.
|
||||
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
|
||||
totals: {
|
||||
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
|
||||
shippingAmountMinor: 0, totalAmountMinor: -32310,
|
||||
},
|
||||
doc: {
|
||||
kind: 'storno',
|
||||
invoiceNumber: 'R-2026-0080',
|
||||
issueDate: '2026-05-15',
|
||||
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
},
|
||||
}));
|
||||
expect(stornoBuf.length).toBeGreaterThan(0);
|
||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF rendering helpers. These are the
|
||||
* functions that DON'T touch PDFKit / DB — formatting, salutation
|
||||
* routing, EPC payload construction.
|
||||
*
|
||||
* The helpers aren't directly exported from pdfService.js (it
|
||||
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
|
||||
* them via `_internal` which the module already exposes for tests.
|
||||
*/
|
||||
const pdfService = require('../../src/services/pdfService');
|
||||
const { formatMinor, formatDate, t } = pdfService._internal;
|
||||
|
||||
describe('formatMinor', () => {
|
||||
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
|
||||
// de-CH uses ’ (U+2019) as the thousands separator.
|
||||
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[’',\u2019]?234\.56/);
|
||||
});
|
||||
|
||||
it('formats large amounts with thousands separators', () => {
|
||||
// 12345600 minor units = 123,456.00 major; the separator
|
||||
// varies by locale (de-CH = U+2019, en-GB = ',').
|
||||
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[’',\u2019]456\.00/);
|
||||
});
|
||||
|
||||
it('returns 0,00 for zero or null', () => {
|
||||
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
|
||||
});
|
||||
|
||||
it('returns 2-decimal output regardless of locale', () => {
|
||||
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
// formatDate now respects ctx.dateFormat (object with `format`
|
||||
// key) — when omitted defaults to DD.MM.YYYY.
|
||||
it('defaults to DD.MM.YYYY when no format passed', () => {
|
||||
expect(formatDate('2026-04-19')).toBe('19.04.2026');
|
||||
});
|
||||
|
||||
it('honors the configured DD/MM/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
|
||||
});
|
||||
|
||||
it('honors the configured MM/DD/YYYY format', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
|
||||
});
|
||||
|
||||
it('honors ISO YYYY-MM-DD', () => {
|
||||
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
|
||||
});
|
||||
|
||||
it('returns empty string on empty input', () => {
|
||||
expect(formatDate('')).toBe('');
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string on invalid input rather than throwing', () => {
|
||||
expect(formatDate('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('accepts Date objects', () => {
|
||||
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('t (i18n lookup)', () => {
|
||||
it('returns the EN value for an EN-only locale', () => {
|
||||
expect(t('en', 'invoice_title')).toBe('Invoice');
|
||||
expect(t('en', 'quote_title')).toBe('Quote');
|
||||
});
|
||||
|
||||
it('returns the DE value for de locale', () => {
|
||||
expect(t('de', 'invoice_title')).toBe('Rechnung');
|
||||
expect(t('de', 'quote_title')).toBe('Angebot');
|
||||
});
|
||||
|
||||
it('falls back to EN for unknown locales', () => {
|
||||
expect(t('xx', 'invoice_title')).toBe('Invoice');
|
||||
});
|
||||
|
||||
it('substitutes named tokens like {percent}', () => {
|
||||
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
|
||||
expect(out).toMatch(/3% discount if paid within 5 working days\./);
|
||||
});
|
||||
|
||||
it('falls back to EN when the key is missing on the requested locale', () => {
|
||||
// page_of is seeded on all locales — pick something that
|
||||
// exists on EN with a substitution.
|
||||
const out = t('zz', 'page_of', { current: 1, total: 3 });
|
||||
expect(out).toBe('Page 1 of 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Tests for the migration-119 hierarchy support in quoteService:
|
||||
* computeTotals, validateLineItemHierarchy, and the two-phase
|
||||
* insertLineItemsHierarchical helper. All pure / db-mocked so the
|
||||
* suite runs fast and is deterministic.
|
||||
*/
|
||||
const quoteService = require('../../src/services/quoteService');
|
||||
const {
|
||||
computeTotals,
|
||||
validateLineItemHierarchy,
|
||||
insertLineItemsHierarchical,
|
||||
} = quoteService._internal;
|
||||
|
||||
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
|
||||
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
|
||||
const items = [
|
||||
// Parent with its own price €500 — should be IGNORED because
|
||||
// sub-items have prices. Parent's effective line_total becomes
|
||||
// sum of priced sub-items.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
// Priced sub-items €150 + €200 = €350
|
||||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||||
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
|
||||
// Another top-level item: €100
|
||||
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
|
||||
// Parent's own €500 is silently overridden.
|
||||
expect(out.netAmountMinor).toBe(45000);
|
||||
// Parent's stored line_total_minor reflects the resolved sum.
|
||||
expect(out.lineItems[0].line_total_minor).toBe(35000);
|
||||
});
|
||||
|
||||
it('priceless sub-items leave the parent\'s own line_total intact', () => {
|
||||
const items = [
|
||||
// Parent €500 with three priceless transparency-bullets — the
|
||||
// €500 stands.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
expect(out.netAmountMinor).toBe(50000);
|
||||
expect(out.lineItems[0].line_total_minor).toBe(50000);
|
||||
});
|
||||
|
||||
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
|
||||
const items = [
|
||||
// Parent €500 → overridden because at least one sub-item is priced.
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
|
||||
// Priceless bullet — doesn't add anything
|
||||
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
|
||||
];
|
||||
const out = computeTotals(items, 0, 0);
|
||||
// Parent resolves to €150 (only priced sub-item).
|
||||
expect(out.netAmountMinor).toBe(15000);
|
||||
expect(out.lineItems[0].line_total_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
|
||||
const out = computeTotals([
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
|
||||
], 0);
|
||||
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
|
||||
});
|
||||
|
||||
it('applies VAT to the resolved parent total', () => {
|
||||
const out = computeTotals([
|
||||
// Parent €1000 overridden by priced €800 sub-item
|
||||
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
|
||||
], 7.7);
|
||||
// Resolved net = 80000, VAT 7.7% = 6160.
|
||||
expect(out.netAmountMinor).toBe(80000);
|
||||
expect(out.vatAmountMinor).toBe(6160);
|
||||
expect(out.totalAmountMinor).toBe(86160);
|
||||
});
|
||||
|
||||
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
|
||||
const out = computeTotals([
|
||||
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
|
||||
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
|
||||
], 0);
|
||||
expect(out.netAmountMinor).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLineItemHierarchy', () => {
|
||||
it('accepts a flat list of top-level items', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2 },
|
||||
{ position: 3 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts one level of sub-items under valid parents', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2, parent_position: 1 },
|
||||
{ position: 3, parent_position: 1 },
|
||||
{ position: 4 },
|
||||
{ position: 5, parent_position: 4 },
|
||||
])).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects duplicate positions', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 1 },
|
||||
])).toThrow(/Duplicate line item position/);
|
||||
});
|
||||
|
||||
it('rejects a sub-item pointing at a missing parent', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1, parent_position: 99 },
|
||||
])).toThrow(/missing parent position/);
|
||||
});
|
||||
|
||||
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 1 },
|
||||
{ position: 2, parent_position: 1 },
|
||||
{ position: 3, parent_position: 2 },
|
||||
])).toThrow(/max one level deep/);
|
||||
});
|
||||
|
||||
it('rejects an item whose parent is itself', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 5, parent_position: 5 },
|
||||
])).toThrow(/cannot be its own parent/);
|
||||
});
|
||||
|
||||
it('rejects an item without a positive position', () => {
|
||||
expect(() => validateLineItemHierarchy([
|
||||
{ position: 0 },
|
||||
])).toThrow(/positive position/);
|
||||
});
|
||||
|
||||
it('is a no-op on empty / non-array input', () => {
|
||||
expect(() => validateLineItemHierarchy([])).not.toThrow();
|
||||
expect(() => validateLineItemHierarchy(null)).not.toThrow();
|
||||
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLineItemsHierarchical', () => {
|
||||
// Tiny trx mock — captures insert calls so we can verify the
|
||||
// two-phase ordering and the parent-id remap. `.returning('id')`
|
||||
// returns a synthesised id matching the call order.
|
||||
function makeTrxMock() {
|
||||
let nextId = 100;
|
||||
const inserts = []; // [{ table, row }]
|
||||
const trx = (tableName) => ({
|
||||
insert(row) {
|
||||
const id = nextId++;
|
||||
inserts.push({ table: tableName, row: { ...row, id } });
|
||||
return {
|
||||
returning() { return Promise.resolve([{ id }]); },
|
||||
then(resolve) { return Promise.resolve(undefined).then(resolve); }, // bare await: no returning() call
|
||||
};
|
||||
},
|
||||
});
|
||||
return { trx, inserts };
|
||||
}
|
||||
|
||||
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||||
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
|
||||
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
|
||||
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
|
||||
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
|
||||
]);
|
||||
// 4 inserts, all into quote_line_items.
|
||||
expect(inserts).toHaveLength(4);
|
||||
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
|
||||
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
|
||||
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
|
||||
// Top-level items have parent_line_item_id = null.
|
||||
expect(inserts[0].row.parent_line_item_id).toBeNull();
|
||||
expect(inserts[1].row.parent_line_item_id).toBeNull();
|
||||
// Sub-items reference the id returned for position-1 parent (100).
|
||||
expect(inserts[2].row.parent_line_item_id).toBe(100);
|
||||
expect(inserts[3].row.parent_line_item_id).toBe(100);
|
||||
// parent_position is stripped (wire-only field, not a DB column).
|
||||
expect(inserts[0].row).not.toHaveProperty('parent_position');
|
||||
expect(inserts[2].row).not.toHaveProperty('parent_position');
|
||||
});
|
||||
|
||||
it('copies details_text through to the inserted row', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
|
||||
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
|
||||
]);
|
||||
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
|
||||
});
|
||||
|
||||
it('is a no-op on empty items array', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
|
||||
expect(inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
|
||||
const { trx, inserts } = makeTrxMock();
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
|
||||
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
|
||||
]);
|
||||
expect(inserts[0].table).toBe('invoice_line_items');
|
||||
expect(inserts[0].row.invoice_id).toBe(42);
|
||||
expect(inserts[0].row).not.toHaveProperty('quote_id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for quoteService lock + state-transition guards:
|
||||
* - updateQuote refuses on accepted / declined / converted
|
||||
* - adminAcceptQuote refuses on already-terminal states + atomic
|
||||
* update path
|
||||
*
|
||||
* db deep-mocked, same chain pattern as invoiceService tests.
|
||||
*/
|
||||
|
||||
const tableChains = {};
|
||||
function makeChain() {
|
||||
return {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
// knex chains are thenable; mirror that so `await trx('t')...`
|
||||
// resolves to an array of rows.
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNotIn: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: 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 Promise.resolve(this._selectResult); }),
|
||||
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); }),
|
||||
leftJoin: 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; }),
|
||||
};
|
||||
}
|
||||
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));
|
||||
|
||||
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/services/pdfService', () => ({
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderInvoiceToBuffer: 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 quoteService = require('../../src/services/quoteService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
}
|
||||
|
||||
describe('quoteService.updateQuote — lock guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('404s when the quote does not exist', async () => {
|
||||
pickChainFor('quotes')._firstValue = null;
|
||||
await expect(quoteService.updateQuote(99, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('locks accepted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('locks declined quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('locks converted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
|
||||
await expect(quoteService.updateQuote(1, {}, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
|
||||
});
|
||||
|
||||
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
|
||||
for (const status of ['draft', 'sent', 'expired']) {
|
||||
pickChainFor('quotes')._firstValue = {
|
||||
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
|
||||
};
|
||||
// The lock check sits at the TOP of updateQuote. The
|
||||
// observable behavior we care about is "no QUOTE_LOCKED
|
||||
// 409 thrown on these statuses". The full transaction
|
||||
// path may resolve to anything (incl. undefined) since
|
||||
// the test mocks the trx callback — that's fine.
|
||||
let err = null;
|
||||
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
|
||||
catch (e) { err = e; }
|
||||
if (err) {
|
||||
// Any error other than the QUOTE_LOCKED guard is allowed
|
||||
// (we're not exercising the full path here).
|
||||
expect(err.code).not.toBe('QUOTE_LOCKED');
|
||||
}
|
||||
resetChains();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoteService.adminAcceptQuote', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('404s when the quote does not exist', async () => {
|
||||
pickChainFor('quotes')._firstValue = null;
|
||||
await expect(quoteService.adminAcceptQuote(99, 1))
|
||||
.rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('refuses already-accepted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
|
||||
});
|
||||
|
||||
it('refuses declined quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
|
||||
});
|
||||
|
||||
it('refuses converted quotes', async () => {
|
||||
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
|
||||
await expect(quoteService.adminAcceptQuote(1, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
|
||||
});
|
||||
|
||||
it('accepts draft / sent / expired and returns lockedAt', async () => {
|
||||
for (const status of ['draft', 'sent', 'expired']) {
|
||||
pickChainFor('quotes')._firstValue = {
|
||||
id: 1, status, customer_account_id: 5,
|
||||
currency: 'CHF', language: 'de',
|
||||
quote_number: 'Q-2026-0001',
|
||||
total_amount_minor: 10000,
|
||||
event_name: null,
|
||||
};
|
||||
pickChainFor('customer_accounts')._firstValue = {
|
||||
id: 5, email: '[email protected]', display_name: 'Test',
|
||||
};
|
||||
pickChainFor('quote_line_items')._selectResult = [];
|
||||
pickChainFor('business_profile')._firstValue = null;
|
||||
|
||||
const result = await quoteService.adminAcceptQuote(1, 42);
|
||||
expect(result.status).toBe('accepted');
|
||||
expect(result.lockedAt).toBeInstanceOf(Date);
|
||||
resetChains();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Smoke tests for taxReportService.renderTaxReportPdf and
|
||||
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
|
||||
* businessProfileService (canned issuer) and assert that the
|
||||
* rendered output meets a few hard requirements:
|
||||
*
|
||||
* - PDF starts with the %PDF magic bytes, is non-empty
|
||||
* - CSV header contains the localised column names
|
||||
* - CSV body contains the invoice numbers in order
|
||||
* - CSV totals row contains the grand totals
|
||||
*/
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
return {
|
||||
_rows: initialRows,
|
||||
then(onResolve, onReject) {
|
||||
return Promise.resolve(this._rows).then(onResolve, onReject);
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
callCount += 1;
|
||||
// Route by table name when supplied — the Skonto aggregate (added
|
||||
// by migration 126) queries `invoice_payment_log`; everything else
|
||||
// (main listing, replacements lookup) hits `invoices`.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments so the raw() return value just needs to exist.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({
|
||||
profile: {
|
||||
company_name: 'ACME Test GmbH',
|
||||
address_line1: 'Teststrasse 1',
|
||||
postal_code: '8000',
|
||||
city: 'Zürich',
|
||||
country_code: 'CH',
|
||||
email: '[email protected]',
|
||||
default_locale: 'de',
|
||||
default_currency: 'CHF',
|
||||
pdf_show_logo: 1,
|
||||
pdf_show_company_name: 1,
|
||||
pdf_logo_height: 56,
|
||||
pdf_company_name_inline: 0,
|
||||
pdf_folding_marks: 'none',
|
||||
logo_path: null,
|
||||
pdf_font_ttf_path: null,
|
||||
},
|
||||
bankAccounts: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
|
||||
}));
|
||||
|
||||
const taxReportService = require('../../src/services/taxReportService');
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
|
||||
const SAMPLE_ROW = (override = {}) => ({
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
|
||||
customer_last_name: null, customer_display_name: null, customer_email: null,
|
||||
event_name: 'Hochzeit Müller',
|
||||
...override,
|
||||
});
|
||||
|
||||
describe('renderTaxReportPdf', () => {
|
||||
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders a header even when no invoices are in the period', async () => {
|
||||
invoiceRowsForRun = [];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders successfully when cancelled rows are present', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
|
||||
];
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders without throwing when a row has long text that must wrap', async () => {
|
||||
// Customer + event labels long enough to force multi-line wrap
|
||||
// in their narrow columns. The dynamic row-height logic should
|
||||
// grow the row to fit rather than overlapping the next one.
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
|
||||
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
|
||||
}),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(buf.length).toBeGreaterThan(500);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('honours the locale parameter (en) without throwing', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
// Regression: the page-number footer used to place its baseline
|
||||
// inside the bottom margin, which made PDFKit auto-paginate one
|
||||
// empty page per existing page (so a 1-page report ended up as 2,
|
||||
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
|
||||
// is the cheapest way to detect a recurrence without parsing the
|
||||
// PDF — every page object in the xref table carries that marker
|
||||
// exactly once.
|
||||
it('does not duplicate pages when stamping the page-number footer', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const buf = await taxReportService.renderTaxReportPdf({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
|
||||
// Small single-row report should fit on a single page. The
|
||||
// previous buggy renderer produced 2 (1 content + 1 footer-only).
|
||||
expect(pageMarkers.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTaxReportCsv', () => {
|
||||
it('returns a CSV blob with the de localised header row', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
|
||||
});
|
||||
expect(contentType).toMatch(/text\/csv/);
|
||||
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
|
||||
const lines = content.split('\r\n');
|
||||
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice
|
||||
expect(lines[0]).toContain('Kunde');
|
||||
expect(lines[0]).toContain('Netto');
|
||||
});
|
||||
|
||||
it('lists each invoice on its own row in order', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
|
||||
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
|
||||
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
const idxA = content.indexOf('R-2026-0001');
|
||||
const idxB = content.indexOf('R-2026-0002');
|
||||
const idxC = content.indexOf('R-2026-0003');
|
||||
expect(idxA).toBeGreaterThan(0);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(idxC).toBeGreaterThan(idxB);
|
||||
});
|
||||
|
||||
it('appends a trailing totals row with the grand totals', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
}),
|
||||
SAMPLE_ROW({
|
||||
id: 2, invoice_number: 'R-2026-0002',
|
||||
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
|
||||
}),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
|
||||
expect(content).toMatch(/"150\.00"/);
|
||||
expect(content).toMatch(/"11\.55"/);
|
||||
expect(content).toMatch(/"161\.55"/);
|
||||
});
|
||||
|
||||
it('marks cancelled rows with a 1 in the cancelled column', async () => {
|
||||
invoiceRowsForRun = [
|
||||
SAMPLE_ROW({ status: 'cancelled' }),
|
||||
];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
// Migration 126 added a trailing Skonto column. The cancelled
|
||||
// marker is now second-to-last; the Skonto cell is empty for
|
||||
// non-Skonto rows. Asserting on a regex keeps the test stable
|
||||
// against future trailing-column additions.
|
||||
const dataRow = content.split('\r\n')[1];
|
||||
expect(/"1","[^"]*"$/.test(dataRow)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
|
||||
invoiceRowsForRun = [SAMPLE_ROW()];
|
||||
const { content } = await taxReportService.renderTaxReportCsv({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
|
||||
});
|
||||
expect(content).toContain('\r\n');
|
||||
// The route wraps the BOM around the content; the service output
|
||||
// itself is BOM-free so callers (tests) get a clean string.
|
||||
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Tests for taxReportService.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
|
||||
* buildCustomerLabel) — no db mock needed.
|
||||
* 2. getTaxReport — db chain deep-mocked so we can feed canned
|
||||
* invoice rows and assert the filter/bucket/total math.
|
||||
*/
|
||||
|
||||
// ----- mock db chain ---------------------------------------------------
|
||||
//
|
||||
// taxReportService builds a single chain:
|
||||
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
|
||||
// .where(...).whereIn(...).orderBy(...).select(...)
|
||||
// and then for cancelled ids:
|
||||
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
|
||||
//
|
||||
// We use one shared chain factory that returns canned rows from
|
||||
// `_selectResult` for the main query, and lets us swap the result
|
||||
// for the replacements lookup via a "second-call" hook.
|
||||
|
||||
let invoiceRowsForRun = [];
|
||||
let replacementsRowsForRun = [];
|
||||
let callCount = 0;
|
||||
|
||||
function makeChain(initialRows) {
|
||||
const c = {
|
||||
_rows: initialRows,
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._rows).then(onResolve, onReject);
|
||||
},
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereBetween: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((tableName) => {
|
||||
callCount += 1;
|
||||
// Migration 126 added a Skonto aggregate that hits
|
||||
// `invoice_payment_log` — route those explicitly to an empty list so
|
||||
// the test surface stays focused on the invoices/replacements flow.
|
||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
||||
// First call: main listing. Second call: replacements lookup.
|
||||
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||
return makeChain(replacementsRowsForRun);
|
||||
});
|
||||
// `.raw()` is used in the .select() column list for the event_name
|
||||
// COALESCE (migration 123). The chain's select() ignores its
|
||||
// arguments and returns the mocked rows, so the raw() return value
|
||||
// just needs to exist — a string is fine.
|
||||
mockDbFn.raw = jest.fn((sql) => sql);
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
const taxReportService = require('../../src/services/taxReportService');
|
||||
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceRowsForRun = [];
|
||||
replacementsRowsForRun = [];
|
||||
callCount = 0;
|
||||
mockDbFn.mockClear();
|
||||
});
|
||||
|
||||
// ----- pure helpers ----------------------------------------------------
|
||||
|
||||
describe('grossUpLateFee', () => {
|
||||
it('returns zeros for a zero or negative fee', () => {
|
||||
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
|
||||
});
|
||||
|
||||
it('returns the whole fee as net when VAT rate is 0', () => {
|
||||
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
|
||||
// Missing/invalid rate is treated the same.
|
||||
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
|
||||
});
|
||||
|
||||
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
|
||||
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
|
||||
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
|
||||
});
|
||||
|
||||
it('guarantees net + vat === gross input (no rounding drift)', () => {
|
||||
for (const fee of [1, 2500, 9999, 12345, 250000]) {
|
||||
for (const rate of [7.7, 8.1, 19, 20.5]) {
|
||||
const { net, vat } = grossUpLateFee(fee, rate);
|
||||
expect(net + vat).toBe(fee);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeReportedAmounts', () => {
|
||||
it('returns stored amounts unchanged when late fee is zero', () => {
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 770,
|
||||
total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0,
|
||||
vat_rate: 7.7,
|
||||
});
|
||||
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
|
||||
});
|
||||
|
||||
it('adds the late-fee net/vat split onto the stored net + vat', () => {
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 10000,
|
||||
vat_amount_minor: 770,
|
||||
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
|
||||
late_fee_amount_minor: 2500,
|
||||
vat_rate: 7.7,
|
||||
});
|
||||
expect(r.netMinor).toBe(10000 + 2321);
|
||||
expect(r.vatMinor).toBe(770 + 179);
|
||||
expect(r.totalMinor).toBe(13270);
|
||||
});
|
||||
|
||||
it('keeps total at the stored total even when late fee is present', () => {
|
||||
// The stored total already includes the late fee — we never
|
||||
// recompute it from net + vat in the report.
|
||||
const r = computeReportedAmounts({
|
||||
net_amount_minor: 50000,
|
||||
vat_amount_minor: 4050,
|
||||
total_amount_minor: 56550,
|
||||
late_fee_amount_minor: 2500,
|
||||
vat_rate: 8.1,
|
||||
});
|
||||
expect(r.totalMinor).toBe(56550);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCustomerLabel', () => {
|
||||
it('prefers company_name when present', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_company_name: 'ACME GmbH',
|
||||
customer_first_name: 'Anna',
|
||||
customer_last_name: 'Beispiel',
|
||||
customer_email: '[email protected]',
|
||||
})).toBe('ACME GmbH');
|
||||
});
|
||||
|
||||
it('falls back to first + last name', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_company_name: '',
|
||||
customer_first_name: 'Anna',
|
||||
customer_last_name: 'Beispiel',
|
||||
})).toBe('Anna Beispiel');
|
||||
});
|
||||
|
||||
it('falls back to display_name when no name parts', () => {
|
||||
expect(buildCustomerLabel({
|
||||
customer_display_name: 'Anna B.',
|
||||
})).toBe('Anna B.');
|
||||
});
|
||||
|
||||
it('falls back to email as a last resort', () => {
|
||||
expect(buildCustomerLabel({ customer_email: '[email protected]' })).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('returns empty string when nothing usable is present', () => {
|
||||
expect(buildCustomerLabel({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ----- getTaxReport ----------------------------------------------------
|
||||
|
||||
describe('getTaxReport', () => {
|
||||
it('throws when from/to or currency are missing', async () => {
|
||||
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
|
||||
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
|
||||
.rejects.toThrow(/currency/);
|
||||
});
|
||||
|
||||
it('returns rows + totals for a clean period with one paid invoice', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
|
||||
});
|
||||
expect(out.currency).toBe('CHF');
|
||||
expect(out.rows).toHaveLength(1);
|
||||
expect(out.rows[0]).toMatchObject({
|
||||
invoiceNumber: 'R-2026-0001',
|
||||
isCancelled: false,
|
||||
customerLabel: 'ACME GmbH',
|
||||
eventName: 'Wedding A',
|
||||
netMinor: 10000,
|
||||
vatMinor: 770,
|
||||
totalMinor: 10770,
|
||||
});
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.cancelledCount).toBe(0);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
|
||||
});
|
||||
|
||||
it('keeps cancelled rows visible but excludes them from totals', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
|
||||
currency: 'CHF', status: 'cancelled', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
{
|
||||
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: 10,
|
||||
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
|
||||
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
// The supersedes lookup query: row 11 supersedes row 10.
|
||||
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
|
||||
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.rows).toHaveLength(2);
|
||||
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
|
||||
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
|
||||
expect(cancelled.isCancelled).toBe(true);
|
||||
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
|
||||
expect(replacement.isCancelled).toBe(false);
|
||||
|
||||
// Totals: only the replacement counts.
|
||||
expect(out.grandTotalNet).toBe(10000);
|
||||
expect(out.grandTotalVat).toBe(770);
|
||||
expect(out.grandTotal).toBe(10770);
|
||||
expect(out.cancelledCount).toBe(1);
|
||||
expect(out.totalsByVatRate).toEqual([
|
||||
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'A', event_name: 'X',
|
||||
},
|
||||
{
|
||||
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
|
||||
currency: 'CHF', status: 'paid', vat_rate: 8.1,
|
||||
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'B', event_name: 'Y',
|
||||
},
|
||||
{
|
||||
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
|
||||
currency: 'CHF', status: 'sent', vat_rate: 8.1,
|
||||
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
|
||||
late_fee_amount_minor: 0, replaces_invoice_id: null,
|
||||
customer_company_name: 'C', event_name: 'Z',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.totalsByVatRate).toHaveLength(2);
|
||||
// Sorted ascending by rate.
|
||||
expect(out.totalsByVatRate[0]).toEqual({
|
||||
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
|
||||
});
|
||||
expect(out.totalsByVatRate[1]).toEqual({
|
||||
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
|
||||
});
|
||||
expect(out.grandTotalNet).toBe(35000);
|
||||
expect(out.grandTotalVat).toBe(2795);
|
||||
expect(out.grandTotal).toBe(37795);
|
||||
});
|
||||
|
||||
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
|
||||
invoiceRowsForRun = [
|
||||
{
|
||||
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
|
||||
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
|
||||
net_amount_minor: 10000, vat_amount_minor: 770,
|
||||
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
|
||||
late_fee_amount_minor: 2500, replaces_invoice_id: null,
|
||||
customer_company_name: 'ACME', event_name: 'Wedding A',
|
||||
},
|
||||
];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
|
||||
expect(out.rows[0].netMinor).toBe(12321);
|
||||
expect(out.rows[0].vatMinor).toBe(949);
|
||||
expect(out.rows[0].totalMinor).toBe(13270);
|
||||
// Grand totals reflect the same gross-up math.
|
||||
expect(out.grandTotalNet).toBe(12321);
|
||||
expect(out.grandTotalVat).toBe(949);
|
||||
expect(out.grandTotal).toBe(13270);
|
||||
});
|
||||
|
||||
it('returns empty rows + zero totals when no invoices match the period', async () => {
|
||||
invoiceRowsForRun = [];
|
||||
const out = await taxReportService.getTaxReport({
|
||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
||||
});
|
||||
expect(out.rows).toEqual([]);
|
||||
expect(out.grandTotalNet).toBe(0);
|
||||
expect(out.grandTotalVat).toBe(0);
|
||||
expect(out.grandTotal).toBe(0);
|
||||
expect(out.totalsByVatRate).toEqual([]);
|
||||
expect(out.cancelledCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Coverage for the changelog aggregation introduced for #567.
|
||||
*
|
||||
* `getReleasesSince` is what feeds the update-available modal so the
|
||||
* admin can see release notes for every version between their current
|
||||
* version and latest. The cases below pin:
|
||||
*
|
||||
* - Strictly-newer filtering (the running version itself never
|
||||
* appears in the list).
|
||||
* - Channel filtering (a stable user does not see beta releases,
|
||||
* and vice versa).
|
||||
* - Empty-result fallback when the GitHub fetch returns nothing
|
||||
* (cached null from `fetchAvailableVersions`).
|
||||
*
|
||||
* We mock axios's GitHub response — these tests are pure logic, no
|
||||
* network. Cache is cleared between cases via the service's exported
|
||||
* `clearCache()` to avoid bleed.
|
||||
*/
|
||||
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
|
||||
|
||||
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
|
||||
return {
|
||||
tag_name: tag,
|
||||
name: tag,
|
||||
body,
|
||||
published_at: publishedAt,
|
||||
html_url: `https://github.com/the-luap/picpeak/releases/tag/${tag}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('updateCheckService.getReleasesSince', () => {
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
axios.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns only releases strictly newer than current, for the requested channel', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [
|
||||
release('v3.55.0', 'stable notes 3.55.0'),
|
||||
release('v3.54.0', 'stable notes 3.54.0'),
|
||||
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
|
||||
release('v3.43.0'),
|
||||
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
|
||||
release('v3.54.0-beta.5'),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.43.1', 'stable');
|
||||
|
||||
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
|
||||
// Body + html_url + publishedAt are preserved so the modal can render them
|
||||
expect(result[0]).toMatchObject({
|
||||
version: '3.55.0',
|
||||
tag: 'v3.55.0',
|
||||
name: 'v3.55.0',
|
||||
body: 'stable notes 3.55.0',
|
||||
htmlUrl: 'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only beta releases for a beta user', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [
|
||||
release('v3.55.0-beta.0'),
|
||||
release('v3.54.7-beta.0'),
|
||||
release('v3.55.0'), // stable — wrong channel for a beta user
|
||||
release('v3.54.6-beta.0'),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
|
||||
|
||||
// Strictly newer beta-channel only — does not include 3.55.0 stable
|
||||
// even though it's a newer release, because the beta channel user
|
||||
// wants to see beta releases (which can include releases that
|
||||
// landed on the beta line after the stable cut).
|
||||
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
|
||||
});
|
||||
|
||||
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
|
||||
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
|
||||
|
||||
const result = await getReleasesSince('3.43.1', 'stable');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when the user is already on the latest version', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: [release('v3.55.0'), release('v3.54.0')],
|
||||
});
|
||||
|
||||
const result = await getReleasesSince('3.55.0', 'stable');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests for the ISO 13616 IBAN validator.
|
||||
*
|
||||
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
|
||||
* — they are publicly published sample values used by every IBAN
|
||||
* implementation as test vectors. NOT real account numbers.
|
||||
*/
|
||||
const { validateIban, _internal } = require('../../src/utils/iban');
|
||||
|
||||
describe('validateIban', () => {
|
||||
it('accepts a canonical Swiss IBAN', () => {
|
||||
const out = validateIban('CH9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
expect(out.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a Liechtenstein IBAN', () => {
|
||||
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a German IBAN', () => {
|
||||
expect(validateIban('DE89370400440532013000').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an Austrian IBAN', () => {
|
||||
expect(validateIban('AT611904300234573201').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a British IBAN with alphanumeric BBAN', () => {
|
||||
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
|
||||
});
|
||||
|
||||
it('normalises spaces and lowercase input', () => {
|
||||
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('normalises mixed-case input', () => {
|
||||
const out = validateIban('ch9300762011623852957');
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.normalized).toBe('CH9300762011623852957');
|
||||
});
|
||||
|
||||
it('rejects an empty / null / undefined value', () => {
|
||||
expect(validateIban('').reason).toBe('EMPTY');
|
||||
expect(validateIban(' ').reason).toBe('EMPTY');
|
||||
expect(validateIban(null).reason).toBe('EMPTY');
|
||||
expect(validateIban(undefined).reason).toBe('EMPTY');
|
||||
});
|
||||
|
||||
it('rejects a malformed string (numbers in the country slot)', () => {
|
||||
const out = validateIban('12930076201162385295');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-short string', () => {
|
||||
expect(validateIban('CH93').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a too-long string (over 34 chars)', () => {
|
||||
// 35 chars: pads beyond the ISO 13616 max
|
||||
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('rejects a known-country IBAN with the wrong length', () => {
|
||||
// CH must be 21 chars; this one is 22.
|
||||
const out = validateIban('CH9300762011623852957X');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('LENGTH');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with a broken checksum', () => {
|
||||
// Same shape, last digit altered.
|
||||
const out = validateIban('CH9300762011623852950');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM');
|
||||
});
|
||||
|
||||
it('rejects an IBAN with internally invalid characters', () => {
|
||||
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
|
||||
});
|
||||
|
||||
it('accepts an unknown-country IBAN that meets the generic length range', () => {
|
||||
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
|
||||
// structural regex passes if length is in [15, 34] and the
|
||||
// checksum holds. Build a checksum-valid string:
|
||||
//
|
||||
// Format: ZZ + check + BBAN. We don't have a real ZZ template
|
||||
// so this test just confirms unknown country codes route
|
||||
// through the fallback length check rather than failing on
|
||||
// LENGTH outright. A checksum-failing ZZ value will hit
|
||||
// CHECKSUM, not LENGTH, which is the assertion below.
|
||||
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
|
||||
expect(out.valid).toBe(false);
|
||||
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
|
||||
});
|
||||
});
|
||||
|
||||
describe('mod97', () => {
|
||||
it('returns 1 for the canonical CH test vector', () => {
|
||||
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns something other than 1 for a tampered IBAN', () => {
|
||||
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IBAN_LENGTHS table', () => {
|
||||
it('has the expected lengths for the most common European countries', () => {
|
||||
// Sanity check that the table didn't drift if someone edits it.
|
||||
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
|
||||
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
|
||||
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
|
||||
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
|
||||
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Pure-function tests for the PDF filename builder used on every
|
||||
* quote / invoice download endpoint + the PDF's internal Title
|
||||
* metadata. No mocks needed — all behavior is deterministic.
|
||||
*/
|
||||
const { buildPdfFilename, sanitiseSegment, customerLabel } = require('../../src/utils/pdfFilename');
|
||||
|
||||
describe('sanitiseSegment', () => {
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitiseSegment(null)).toBe('');
|
||||
expect(sanitiseSegment(undefined)).toBe('');
|
||||
expect(sanitiseSegment('')).toBe('');
|
||||
});
|
||||
|
||||
it('replaces filesystem-hostile characters with "-"', () => {
|
||||
expect(sanitiseSegment('a/b\\c:d*e?f"g<h>i|j')).toBe('a-b-c-d-e-f-g-h-i-j');
|
||||
});
|
||||
|
||||
it('collapses spaces into single "-"', () => {
|
||||
expect(sanitiseSegment('ACME GmbH AG')).toBe('ACME-GmbH-AG');
|
||||
});
|
||||
|
||||
it('collapses repeat dashes', () => {
|
||||
expect(sanitiseSegment('a-----b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('trims leading + trailing dashes/dots', () => {
|
||||
expect(sanitiseSegment('--..--Hello..--..')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('preserves non-ASCII letters', () => {
|
||||
expect(sanitiseSegment('Müller & Söhne')).toBe('Müller-&-Söhne');
|
||||
});
|
||||
|
||||
it('caps length at 80 chars by default', () => {
|
||||
const long = 'a'.repeat(120);
|
||||
expect(sanitiseSegment(long)).toHaveLength(80);
|
||||
});
|
||||
|
||||
it('honors custom maxLen', () => {
|
||||
expect(sanitiseSegment('abcdefghij', 5)).toBe('abcde');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customerLabel', () => {
|
||||
it('prefers company_name over person name', () => {
|
||||
expect(customerLabel({
|
||||
company_name: 'ACME GmbH',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('ACME-GmbH');
|
||||
});
|
||||
|
||||
it('falls back to first + last when company_name is empty', () => {
|
||||
expect(customerLabel({
|
||||
company_name: '',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
|
||||
it('falls back to display_name when no company + no person', () => {
|
||||
expect(customerLabel({
|
||||
display_name: 'Luca B.',
|
||||
})).toBe('Luca-B');
|
||||
});
|
||||
|
||||
it('falls back to email local-part as a last resort', () => {
|
||||
expect(customerLabel({
|
||||
email: '[email protected]',
|
||||
})).toBe('luca');
|
||||
});
|
||||
|
||||
it('uses "customer" when everything is missing', () => {
|
||||
expect(customerLabel({})).toBe('customer');
|
||||
expect(customerLabel(null)).toBe('customer');
|
||||
});
|
||||
|
||||
it('trims whitespace before evaluating truthiness', () => {
|
||||
// company_name = " " should NOT trigger the company branch.
|
||||
expect(customerLabel({
|
||||
company_name: ' ',
|
||||
first_name: 'Luca', last_name: 'Bresch',
|
||||
})).toBe('Luca-Bresch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPdfFilename', () => {
|
||||
const customer = { company_name: 'ACME GmbH' };
|
||||
|
||||
it('builds "<docNumber>_<customer>.pdf" for a regular invoice', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer,
|
||||
})).toBe('R-2026-0001_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('falls back to the fallback when docNumber is null (preview)', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: null,
|
||||
customer,
|
||||
fallback: 'invoice-preview',
|
||||
})).toBe('invoice-preview_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('uses "document" when both docNumber + fallback are absent', () => {
|
||||
expect(buildPdfFilename({ customer })).toBe('document_ACME-GmbH.pdf');
|
||||
});
|
||||
|
||||
it('sanitises the customer half too', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: { company_name: 'Bad/Name:Inc.' },
|
||||
})).toBe('R-2026-0001_Bad-Name-Inc.pdf');
|
||||
});
|
||||
|
||||
it('always ends with .pdf', () => {
|
||||
expect(buildPdfFilename({
|
||||
docNumber: 'R-2026-0001',
|
||||
customer: {},
|
||||
})).toMatch(/\.pdf$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* resolveLogoFile — verifies the path-priority chain + the
|
||||
* unsupported-format guard. fs + appSettings + storage config are
|
||||
* mocked so the test is fully deterministic.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/config/storage', () => ({
|
||||
getStoragePath: jest.fn(() => '/app/storage'),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const { resolveLogoFile } = require('../../src/utils/resolveLogoFile');
|
||||
const { getAppSetting } = require('../../src/utils/appSettings');
|
||||
|
||||
describe('resolveLogoFile', () => {
|
||||
let existsSpy, statSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSpy = jest.spyOn(fs, 'existsSync');
|
||||
statSpy = jest.spyOn(fs, 'statSync');
|
||||
existsSpy.mockReturnValue(false);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
getAppSetting.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSpy.mockRestore();
|
||||
statSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns null when no sources are configured', async () => {
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers business_profile.logo_path over branding fallbacks', async () => {
|
||||
// The profile path exists, branding doesn't.
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/profile.png');
|
||||
getAppSetting.mockResolvedValue('/uploads/logos/branding.png');
|
||||
const out = await resolveLogoFile({
|
||||
logo_path: 'uploads/logos/profile.png',
|
||||
});
|
||||
expect(out).toBe('/app/storage/uploads/logos/profile.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_path when profile is empty', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_path') return '/app/storage/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({ logo_path: '' });
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('falls back to branding_logo_url when branding_logo_path is absent', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/branding.png');
|
||||
getAppSetting.mockImplementation(async (key) => {
|
||||
if (key === 'branding_logo_url') return '/uploads/logos/branding.png';
|
||||
return null;
|
||||
});
|
||||
const out = await resolveLogoFile({});
|
||||
expect(out).toBe('/app/storage/uploads/logos/branding.png');
|
||||
});
|
||||
|
||||
it('skips SVG (PDFKit cannot embed)', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.svg');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: 'uploads/logos/logo.svg' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('also rejects WebP / GIF / TIFF', async () => {
|
||||
for (const ext of ['webp', 'gif', 'tif', 'tiff']) {
|
||||
existsSpy.mockReturnValue(true);
|
||||
statSpy.mockImplementation(() => ({ isFile: () => true }));
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts PNG and JPEG', async () => {
|
||||
for (const ext of ['png', 'jpg', 'jpeg', 'PNG', 'JPG']) {
|
||||
existsSpy.mockImplementation((p) => p === `/app/storage/uploads/logos/logo.${ext}`);
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: `uploads/logos/logo.${ext}` });
|
||||
expect(out).toBe(`/app/storage/uploads/logos/logo.${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats absolute paths as-is when they exist', async () => {
|
||||
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
|
||||
getAppSetting.mockResolvedValue(null);
|
||||
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
|
||||
expect(out).toBe('/abs/path/logo.png');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user