d543949188
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
110 lines
3.8 KiB
JavaScript
110 lines
3.8 KiB
JavaScript
/**
|
|
* 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(),
|
|
);
|
|
});
|
|
});
|