test: add smoke tests for invoiceService, adminEvents routes, backupService
26 tests as a safety net ahead of decomposition — invoice create/list/ status transitions, adminEvents CRUD via Supertest+SQLite, backup config parsing and manifest validation.
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for backupService's config resolution + file-collection
|
||||||
|
* and manifest validation paths — safety net ahead of the god-file
|
||||||
|
* decomposition.
|
||||||
|
*
|
||||||
|
* Uses the same real-SQLite harness as
|
||||||
|
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||||
|
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||||
|
* backupService.enhanced.test.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { bootCrmDb } = require('./helpers/crmDb');
|
||||||
|
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let storagePath;
|
||||||
|
let backupService;
|
||||||
|
let backupManifest;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
storagePath = process.env.STORAGE_PATH;
|
||||||
|
backupService = require('../../src/services/backupService');
|
||||||
|
backupManifest = require('../../src/services/backupManifest');
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('app_settings').del();
|
||||||
|
// Reset the storage tree so each test starts from a pristine walk.
|
||||||
|
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedFile(relPath, content = 'dummy bytes') {
|
||||||
|
const abs = path.join(storagePath, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, content);
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertBackupSetting(key, value) {
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: key,
|
||||||
|
setting_value: value,
|
||||||
|
setting_type: 'backup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getBackupConfig', () => {
|
||||||
|
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||||
|
await insertBackupSetting('backup_enabled', 'true');
|
||||||
|
await insertBackupSetting('backup_include_archived', 'false');
|
||||||
|
await insertBackupSetting('backup_retention_days', '30');
|
||||||
|
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||||
|
await insertBackupSetting('backup_email_recipients', '["[email protected]","[email protected]"]');
|
||||||
|
// Non-backup settings must not leak into the backup config.
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'general_site_name',
|
||||||
|
setting_value: 'PicPeak',
|
||||||
|
setting_type: 'general',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await backupService.getBackupConfig();
|
||||||
|
|
||||||
|
expect(config.backup_enabled).toBe(true);
|
||||||
|
expect(config.backup_include_archived).toBe(false);
|
||||||
|
expect(config.backup_retention_days).toBe(30);
|
||||||
|
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||||
|
expect(config.backup_email_recipients).toEqual(['[email protected]', '[email protected]']);
|
||||||
|
expect(config).not.toHaveProperty('general_site_name');
|
||||||
|
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||||
|
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||||
|
const config = await backupService.getBackupConfig();
|
||||||
|
expect(config).not.toBeNull();
|
||||||
|
expect(Object.keys(config)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFilesToBackup', () => {
|
||||||
|
it('returns an empty list on a pristine storage tree', async () => {
|
||||||
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||||
|
expect(files).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||||
|
const content = 'not really a jpeg';
|
||||||
|
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||||
|
|
||||||
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||||
|
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||||
|
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.path).toBe(abs);
|
||||||
|
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||||
|
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||||
|
// realm under Jest and fails the cross-realm instanceof check.
|
||||||
|
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateBackupManifest', () => {
|
||||||
|
it('round-trips a generated manifest as valid', async () => {
|
||||||
|
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||||
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||||
|
|
||||||
|
const manifest = await backupManifest.generateManifest({
|
||||||
|
backupType: 'full',
|
||||||
|
backupPath: '/backup/run-1',
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||||
|
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||||
|
|
||||||
|
const result = await backupService.validateBackupManifest(manifestPath);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.manifest.backup.type).toBe('full');
|
||||||
|
expect(result.manifest.files.count).toBe(files.length);
|
||||||
|
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a manifest missing required sections as invalid', async () => {
|
||||||
|
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||||
|
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||||
|
|
||||||
|
const result = await backupService.validateBackupManifest(badPath);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toMatch(/Missing required section/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* HTTP smoke tests for the core admin event CRUD endpoints:
|
||||||
|
* POST /api/admin/events (create)
|
||||||
|
* GET /api/admin/events (list + pagination)
|
||||||
|
* GET /api/admin/events/:id (detail + stats)
|
||||||
|
* PUT /api/admin/events/:id (update)
|
||||||
|
* DELETE /api/admin/events/:id (cascade delete)
|
||||||
|
*
|
||||||
|
* Safety net ahead of the adminEvents.js god-file decomposition —
|
||||||
|
* pins the request/response contracts of the main CRUD paths using
|
||||||
|
* the same real-SQLite harness as slideshowAdmin.test.js.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
|
||||||
|
);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||||
|
|
||||||
|
async function insertEvent(db, adminId, over = {}) {
|
||||||
|
const base = {
|
||||||
|
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: 'Test Wedding',
|
||||||
|
event_date: '2026-05-29',
|
||||||
|
host_email: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
password_hash: 'x',
|
||||||
|
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||||
|
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
is_active: 1, is_archived: 0, is_draft: 0,
|
||||||
|
created_by: adminId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
const r = await db('events').insert(base).returning('id');
|
||||||
|
return r[0]?.id ?? r[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin events CRUD endpoints (smoke)', () => {
|
||||||
|
let db; let cleanup; let app; let adminId; let token;
|
||||||
|
|
||||||
|
// bootCrmDb's full migration run intermittently exceeds Jest's default
|
||||||
|
// 5s beforeAll timeout on slower CI runners; raise it.
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
({ adminId } = await seedMinimal(db));
|
||||||
|
await assignAdminRole(db, adminId, 'super_admin');
|
||||||
|
token = mintAdminToken(adminId);
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||||
|
});
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => { await cleanup(); });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('email_queue').del();
|
||||||
|
await db('events').del();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
it('401s without an admin token', async () => {
|
||||||
|
const res = await request(app).get('/api/admin/events');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', () => {
|
||||||
|
it('creates an event, mints slug + share link and persists the row', async () => {
|
||||||
|
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: 'Smoke Wedding',
|
||||||
|
event_date: '2026-09-01',
|
||||||
|
// Field requirements default to ON (getEventFieldRequirements)
|
||||||
|
// so customer + admin contact data must be supplied.
|
||||||
|
customer_name: 'Client Person',
|
||||||
|
customer_email: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
require_password: false,
|
||||||
|
is_draft: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id).toBeDefined();
|
||||||
|
expect(res.body.slug).toContain('wedding-smoke-wedding');
|
||||||
|
expect(typeof res.body.share_link).toBe('string');
|
||||||
|
expect(res.body.is_draft).toBe(true);
|
||||||
|
|
||||||
|
const row = await db('events').where({ id: res.body.id }).first();
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.event_name).toBe('Smoke Wedding');
|
||||||
|
expect(row.created_by).toBe(adminId);
|
||||||
|
|
||||||
|
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
|
||||||
|
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
|
||||||
|
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
|
||||||
|
|
||||||
|
// Draft creates must NOT queue the gallery_created email.
|
||||||
|
const queued = await db('email_queue').where({ event_id: res.body.id });
|
||||||
|
expect(queued).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s on an invalid event type', async () => {
|
||||||
|
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||||
|
event_type: 'not-a-real-type',
|
||||||
|
event_name: 'Broken',
|
||||||
|
require_password: false,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(Array.isArray(res.body.errors)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', () => {
|
||||||
|
it('lists events with pagination metadata and photo counts', async () => {
|
||||||
|
await insertEvent(db, adminId, { event_name: 'Alpha' });
|
||||||
|
await insertEvent(db, adminId, { event_name: 'Beta' });
|
||||||
|
|
||||||
|
const res = await auth(request(app).get('/api/admin/events'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.events).toHaveLength(2);
|
||||||
|
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
|
||||||
|
for (const ev of res.body.events) {
|
||||||
|
expect(ev.photo_count).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /:id', () => {
|
||||||
|
it('returns the event with photo/view stats', async () => {
|
||||||
|
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
|
||||||
|
const res = await auth(request(app).get(`/api/admin/events/${id}`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.event_name).toBe('Detail Event');
|
||||||
|
expect(res.body.photo_count).toBe(0);
|
||||||
|
expect(res.body.total_views).toBe(0);
|
||||||
|
expect(res.body.total_downloads).toBe(0);
|
||||||
|
expect(Array.isArray(res.body.recent_photos)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an unknown event id', async () => {
|
||||||
|
const res = await auth(request(app).get('/api/admin/events/999999'));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /:id', () => {
|
||||||
|
it('updates mutable fields and persists them', async () => {
|
||||||
|
const id = await insertEvent(db, adminId, { event_name: 'Before' });
|
||||||
|
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||||
|
event_name: 'After',
|
||||||
|
welcome_message: 'Hello guests',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const row = await db('events').where({ id }).first();
|
||||||
|
expect(row.event_name).toBe('After');
|
||||||
|
expect(row.welcome_message).toBe('Hello guests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s when updating a missing event', async () => {
|
||||||
|
const res = await auth(request(app).put('/api/admin/events/999999')).send({
|
||||||
|
event_name: 'Ghost',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /:id', () => {
|
||||||
|
it('cascade-deletes the event row', async () => {
|
||||||
|
const id = await insertEvent(db, adminId);
|
||||||
|
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.message).toMatch(/deleted/i);
|
||||||
|
const row = await db('events').where({ id }).first();
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s when deleting a missing event', async () => {
|
||||||
|
const res = await auth(request(app).delete('/api/admin/events/999999'));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||||||
|
* decomposition — createInvoice happy path (incl. the line-item
|
||||||
|
* totals/VAT math), list/get reads, and the status-transition guards
|
||||||
|
* on cancelInvoice / releaseForDelivery.
|
||||||
|
*
|
||||||
|
* Uses the same deep-mocked db pattern as
|
||||||
|
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||||||
|
* and assertions probe insert/update 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));
|
||||||
|
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const claimNextSequence = jest.fn(async () => 42);
|
||||||
|
// Delegates to the claimNextSequence mock so call-count assertions
|
||||||
|
// below keep observing sequence claims.
|
||||||
|
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||||
|
const seq = await claimNextSequence(kind, 2026, trx);
|
||||||
|
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||||
|
});
|
||||||
|
return { claimNextSequence, nextDocumentNumber };
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
||||||
|
jest.clearAllMocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCustomer = {
|
||||||
|
id: 5, is_active: 1, feature_bills: 1,
|
||||||
|
billing_cadence: 'per_event', preferred_language: 'de',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createInvoice — happy path + totals', () => {
|
||||||
|
beforeEach(() => resetChains());
|
||||||
|
|
||||||
|
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||||||
|
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||||
|
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||||||
|
|
||||||
|
const result = await invoiceService.createInvoice({
|
||||||
|
customerAccountId: 5,
|
||||||
|
vatRate: 8.1,
|
||||||
|
lineItems: [
|
||||||
|
// 2 × 100.00 = 200.00
|
||||||
|
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||||||
|
// 50.00 with 10% discount = 45.00
|
||||||
|
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||||||
|
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||||||
|
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||||||
|
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||||||
|
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||||||
|
],
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
expect(result.invoiceIds).toEqual([777]);
|
||||||
|
|
||||||
|
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||||||
|
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||||||
|
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
invoice_number: 'R-2026-0042',
|
||||||
|
customer_account_id: 5,
|
||||||
|
currency: 'CHF',
|
||||||
|
status: 'scheduled',
|
||||||
|
net_amount_minor: 59500,
|
||||||
|
vat_rate: 8.1,
|
||||||
|
vat_amount_minor: 4820,
|
||||||
|
shipping_amount_minor: 0,
|
||||||
|
total_amount_minor: 64320,
|
||||||
|
installment_total: 1,
|
||||||
|
}));
|
||||||
|
// Exactly one sequence number claimed for a single-row create.
|
||||||
|
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||||
|
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||||
|
// Line items landed in invoice_line_items.
|
||||||
|
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s on a deactivated customer before touching the sequence', async () => {
|
||||||
|
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||||||
|
await expect(invoiceService.createInvoice({
|
||||||
|
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||||||
|
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||||||
|
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||||
|
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||||||
|
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||||
|
await expect(invoiceService.createInvoice({
|
||||||
|
customerAccountId: 5,
|
||||||
|
vatRate: 7.7,
|
||||||
|
lineItems: [
|
||||||
|
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||||||
|
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||||||
|
],
|
||||||
|
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||||||
|
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||||
|
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||||||
|
beforeEach(() => resetChains());
|
||||||
|
|
||||||
|
it('lists invoices with total + pagination echo', async () => {
|
||||||
|
pickChainFor('invoices')._selectResult = [
|
||||||
|
{ id: 1, invoice_number: 'R-2026-0001' },
|
||||||
|
{ id: 2, invoice_number: 'R-2026-0002' },
|
||||||
|
];
|
||||||
|
pickChainFor('invoices')._firstValue = { total: 7 };
|
||||||
|
|
||||||
|
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||||||
|
|
||||||
|
expect(result.rows).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(7);
|
||||||
|
expect(result.page).toBe(2);
|
||||||
|
expect(result.pageSize).toBe(10);
|
||||||
|
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||||||
|
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||||||
|
pickChainFor('invoice_line_items as li')._selectResult = [
|
||||||
|
{ id: 30, position: 1, description: 'Shoot' },
|
||||||
|
];
|
||||||
|
pickChainFor('invoice_payment_log')._selectResult = [];
|
||||||
|
|
||||||
|
const result = await invoiceService.getInvoiceById(3);
|
||||||
|
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||||||
|
expect(result.lineItems).toHaveLength(1);
|
||||||
|
expect(result.payments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getInvoiceById returns null for an unknown id', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = undefined;
|
||||||
|
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||||||
|
beforeEach(() => resetChains());
|
||||||
|
|
||||||
|
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = {
|
||||||
|
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||||||
|
};
|
||||||
|
const result = await invoiceService.cancelInvoice(9, 1);
|
||||||
|
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||||
|
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'cancelled' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = {
|
||||||
|
id: 9, status: 'cancelled', kind: 'invoice',
|
||||||
|
};
|
||||||
|
await expect(invoiceService.cancelInvoice(9, 1))
|
||||||
|
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = {
|
||||||
|
id: 10, status: 'sent', kind: 'storno',
|
||||||
|
};
|
||||||
|
await expect(invoiceService.cancelInvoice(10, 1))
|
||||||
|
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||||||
|
pickChainFor('invoices')._firstValue = {
|
||||||
|
id: 11, status: 'sent', kind: 'invoice',
|
||||||
|
};
|
||||||
|
await expect(invoiceService.releaseForDelivery(11, 1))
|
||||||
|
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user