From d44ead41c51a6bee60f34c7f20481f8c5c7fe94f Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 07:49:45 +0200 Subject: [PATCH] test: add smoke tests for invoiceService, adminEvents routes, backupService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/backupService.smoke.test.js | 143 ++++++++++ .../routes/adminEvents.smoke.test.js | 200 ++++++++++++++ .../services/invoiceService.smoke.test.js | 259 ++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 backend/__tests__/integration/backupService.smoke.test.js create mode 100644 backend/__tests__/routes/adminEvents.smoke.test.js create mode 100644 backend/__tests__/services/invoiceService.smoke.test.js diff --git a/backend/__tests__/integration/backupService.smoke.test.js b/backend/__tests__/integration/backupService.smoke.test.js new file mode 100644 index 00000000..e1f90fb6 --- /dev/null +++ b/backend/__tests__/integration/backupService.smoke.test.js @@ -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', '["a@example.com","b@example.com"]'); + // 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(['a@example.com', 'b@example.com']); + 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/); + }); + }); +}); diff --git a/backend/__tests__/routes/adminEvents.smoke.test.js b/backend/__tests__/routes/adminEvents.smoke.test.js new file mode 100644 index 00000000..fdee970b --- /dev/null +++ b/backend/__tests__/routes/adminEvents.smoke.test.js @@ -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: 'host@example.com', + admin_email: 'admin@example.com', + 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: 'client@example.com', + admin_email: 'admin@example.com', + 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/. + 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); + }); + }); +}); diff --git a/backend/__tests__/services/invoiceService.smoke.test.js b/backend/__tests__/services/invoiceService.smoke.test.js new file mode 100644 index 00000000..b6ccc0f3 --- /dev/null +++ b/backend/__tests__/services/invoiceService.smoke.test.js @@ -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' }); + }); +});