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
109 lines
4.0 KiB
JavaScript
109 lines
4.0 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|