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
214 lines
7.7 KiB
JavaScript
214 lines
7.7 KiB
JavaScript
/**
|
|
* 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: 'test@example.com',
|
|
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
|
|
createdByAdminId: 5,
|
|
});
|
|
expect(result.id).toBeDefined();
|
|
const inserted = insertResults.customer_accounts[0];
|
|
expect(inserted.email).toBe('test@example.com');
|
|
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: 'de@example.com',
|
|
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: 'fr@example.com',
|
|
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: 'dup@example.com', password_hash: 'whatever' };
|
|
await expect(customerAccountsService.createDirect({
|
|
email: 'dup@example.com',
|
|
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: 'silent@example.com',
|
|
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: 'active@example.com', password_hash: 'hash' };
|
|
await expect(customerAccountsService.createInvitation({
|
|
email: 'active@example.com',
|
|
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: 'passive@example.com', 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: 'passive@example.com',
|
|
invitedById: 9,
|
|
prefill: { first_name: 'Anna' },
|
|
});
|
|
expect(out.id).toBeDefined();
|
|
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|