feat(crm): backend code — services + routes + utilities + tests
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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user