1d84c738d8
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.
adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.
backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).
adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.
crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.
Refs testplan REPORT.md #22 (Part 1.2.01).
419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
/**
|
|
* CRM mint-and-send paths — integration tests (#587).
|
|
*
|
|
* Pins the three document "mint" flows end-to-end through the real
|
|
* HTTP → route → service → DB → email-queue → file pipeline:
|
|
*
|
|
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
|
|
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
|
|
* — the issue spec named this /:id/storno; the real route is
|
|
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
|
|
* 3. POST /api/admin/contracts/:id/countersign
|
|
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
|
|
*
|
|
* Real SQLite with the full core-migration run (helpers/crmDb), real
|
|
* pdfkit/pdf-lib rendering — no mock-fs, no network.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const request = require('supertest');
|
|
const {
|
|
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
|
} = require('./helpers/crmDb');
|
|
|
|
// Full migration run + cold-requiring pdfService/emailProcessor is slow
|
|
// under CI load; match the other CRM integration suites.
|
|
jest.setTimeout(120000);
|
|
|
|
const CUSTOMER_EMAIL = 'customer@example.com';
|
|
|
|
// 1x1 transparent PNG — smallest valid signature pad output.
|
|
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
|
|
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
|
|
// SQLite round-trips dates inconsistently (epoch ms number, numeric
|
|
// string, or ISO string) — parse robustly before comparing.
|
|
const toMillis = (v) => {
|
|
if (typeof v === 'number') return v;
|
|
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
|
return Date.parse(v);
|
|
};
|
|
|
|
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
|
|
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
|
|
// signature stamps (customer + admin) made it into the final PDF instead of
|
|
// only asserting file existence/hash (codex review of #850 round 2).
|
|
async function countImagesPerPage(pdfPath) {
|
|
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
|
|
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
|
|
return doc.getPages().map((page) => {
|
|
const resources = page.node.Resources();
|
|
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
|
|
if (!xobjects) return 0;
|
|
let images = 0;
|
|
for (const [, ref] of xobjects.entries()) {
|
|
const stream = page.doc.context.lookup(ref);
|
|
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
|
|
if (subtype && subtype.toString() === '/Image') images += 1;
|
|
}
|
|
return images;
|
|
});
|
|
}
|
|
|
|
let db;
|
|
let cleanup;
|
|
let tmpDir;
|
|
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
|
|
// /var/... while the services persist under process.cwd() which
|
|
// resolves to /private/var/....
|
|
let storageRoot;
|
|
let adminId;
|
|
let customerId;
|
|
let token;
|
|
let quoteApp;
|
|
let invoiceApp;
|
|
let contractApp;
|
|
let quoteService;
|
|
let invoiceService;
|
|
let contractService;
|
|
|
|
const prevCwd = process.cwd();
|
|
|
|
const auth = { get Authorization() { return `Bearer ${token}`; } };
|
|
|
|
async function enableFlag(key) {
|
|
const updated = await db('feature_flags').where({ key }).update({ value: true });
|
|
if (!updated) await db('feature_flags').insert({ key, value: true });
|
|
}
|
|
|
|
// ----- per-path seed helpers -----------------------------------------
|
|
|
|
async function seedQuote() {
|
|
const id = await quoteService.createQuote({
|
|
customerAccountId: customerId,
|
|
currency: 'CHF',
|
|
vatRate: 0,
|
|
eventName: 'Testshooting',
|
|
lineItems: [
|
|
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
|
|
],
|
|
}, adminId);
|
|
return id;
|
|
}
|
|
|
|
async function seedIssuedInvoice(status = 'sent') {
|
|
const { invoiceIds } = await invoiceService.createInvoice({
|
|
customerAccountId: customerId,
|
|
currency: 'CHF',
|
|
vatRate: 7.7,
|
|
lineItems: [
|
|
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
|
|
],
|
|
}, adminId);
|
|
const id = invoiceIds[0];
|
|
// Fast-forward past the send step — Storno only applies to issued
|
|
// documents (sent/paid/overdue), and rendering+sending the original
|
|
// is covered by the quote path already.
|
|
await db('invoices').where({ id }).update({
|
|
status, sent_at: new Date(), updated_at: new Date(),
|
|
});
|
|
return db('invoices').where({ id }).first();
|
|
}
|
|
|
|
async function seedCustomerSignedContract() {
|
|
const id = await contractService.createContract({
|
|
customerAccountId: customerId,
|
|
title: 'Fotografie-Vertrag',
|
|
}, adminId);
|
|
// Real send + customer-sign flow (codex review of #850): a direct
|
|
// status UPDATE skipped the customer's signature asset and stamped
|
|
// PDF, so countersign exercised its unsigned-PDF fallback and a
|
|
// regression dropping the customer's signature would stay green.
|
|
const { token } = await contractService.sendContract(id, adminId);
|
|
await contractService.recordCustomerSignature({
|
|
token,
|
|
name: 'Custo Mer',
|
|
ip: '127.0.0.1',
|
|
signatureDataUrl: SIGNATURE_DATA_URL,
|
|
accepted: true,
|
|
});
|
|
return db('contracts').where({ id }).first();
|
|
}
|
|
|
|
// ----- suite ----------------------------------------------------------
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup, tmpDir } = await bootCrmDb());
|
|
// Business-doc PDFs (quotes/invoices/contracts) persist under
|
|
// `getStoragePath()/business-docs/...`, and safePath also allows a
|
|
// `process.cwd()/storage/business-docs/...` root — chdir into the temp
|
|
// dir so every test artifact lands isolated and gets cleaned up.
|
|
process.chdir(tmpDir);
|
|
// Mirror what the services store: the raw STORAGE_PATH bootCrmDb
|
|
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
|
|
// /var/... while realpath is /private/var/..., so canonicalizing here
|
|
// would make every stored path fail the prefix check.
|
|
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
|
|
|
|
// Fail-fast on the pre-existing logActivity-inside-transaction
|
|
// deadlock: createContract and createStorno call logActivity() from
|
|
// inside a knex transaction WITHOUT passing the trx as executor, so
|
|
// the audit insert tries to grab a second connection from the
|
|
// single-connection SQLite pool while the trx holds it. In
|
|
// production that stalls each call for the full 60 s acquire
|
|
// timeout (the error is then swallowed by logActivity's catch);
|
|
// here we shrink the timeout so the same swallowed failure costs
|
|
// 2 s instead of blowing the per-test budget. Behaviour under test
|
|
// is unchanged — the mint paths themselves never wait on this.
|
|
db.client.pool.acquireTimeoutMillis = 2000;
|
|
|
|
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
|
|
// against the NATIVE realm's Date — under jest's vm sandbox the
|
|
// service code's `new Date()` is a different constructor, the check
|
|
// fails, and the value stringifies to the literal "[object Object]"
|
|
// (the exact pathology helpers/crmDb.js documents for
|
|
// createPublicToken). Normalize Date bindings to ISO strings before
|
|
// they reach the driver so the real service inserts round-trip the
|
|
// same way they do outside jest.
|
|
// Patch on the prototype — knex mints transaction clients via
|
|
// Object.create(prototype), so an instance-level patch would miss
|
|
// every query issued inside a db.transaction().
|
|
const clientProto = Object.getPrototypeOf(db.client);
|
|
const origQuery = clientProto._query;
|
|
clientProto._query = function patchedQuery(connection, obj) {
|
|
if (obj && Array.isArray(obj.bindings)) {
|
|
obj.bindings = obj.bindings.map(
|
|
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
|
|
);
|
|
}
|
|
return origQuery.call(this, connection, obj);
|
|
};
|
|
|
|
({ adminId, customerId } = await seedMinimal(db));
|
|
await assignAdminRole(db, adminId, 'super_admin');
|
|
token = mintAdminToken(adminId);
|
|
|
|
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
|
|
await enableFlag('quotes');
|
|
await enableFlag('bills');
|
|
await enableFlag('contracts');
|
|
|
|
quoteService = require('../../src/services/quoteService');
|
|
invoiceService = require('../../src/services/invoiceService');
|
|
contractService = require('../../src/services/contractService');
|
|
|
|
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
|
|
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
|
|
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
process.chdir(prevCwd);
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
describe('POST /api/admin/quotes/:id/send', () => {
|
|
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
|
|
const quoteId = await seedQuote();
|
|
await db('email_queue').del();
|
|
|
|
const res = await request(quoteApp)
|
|
.post(`/api/admin/quotes/${quoteId}/send`)
|
|
.set(auth);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.sent).toBe(true);
|
|
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
|
|
|
|
// DB state
|
|
const quote = await db('quotes').where({ id: quoteId }).first();
|
|
expect(quote.status).toBe('sent');
|
|
expect(quote.sent_at).toBeTruthy();
|
|
|
|
// PDF persisted inside the isolated storage root
|
|
expect(quote.pdf_path).toBeTruthy();
|
|
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
|
|
expect(fs.existsSync(quote.pdf_path)).toBe(true);
|
|
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
|
|
|
|
// Action token row: right quote, future expiry
|
|
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
|
|
expect(tokenRow).toBeTruthy();
|
|
expect(tokenRow.quote_id).toBe(quoteId);
|
|
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
|
|
|
|
// Email queued to the customer's primary address
|
|
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
|
|
expect(emails).toHaveLength(1);
|
|
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
|
const emailData = JSON.parse(emails[0].email_data);
|
|
expect(emailData.quote_number).toBe(quote.quote_number);
|
|
});
|
|
|
|
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
|
|
const quoteId = await seedQuote();
|
|
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
|
|
|
|
const res = await request(quoteApp)
|
|
.post(`/api/admin/quotes/${quoteId}/send`)
|
|
.set(auth);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
|
|
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
|
|
const original = await seedIssuedInvoice('sent');
|
|
await db('email_queue').del();
|
|
|
|
const res = await request(invoiceApp)
|
|
.post(`/api/admin/invoices/${original.id}/cancel`)
|
|
.set(auth);
|
|
// Route responds via successResponse default — 200, not the 201
|
|
// the issue spec assumed.
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.cancelled).toBe(true);
|
|
expect(res.body.stornoId).toBeGreaterThan(0);
|
|
|
|
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
|
|
expect(storno.kind).toBe('storno');
|
|
expect(storno.cancels_invoice_id).toBe(original.id);
|
|
expect(storno.deal_uuid).toBe(original.deal_uuid);
|
|
|
|
// Negated amounts
|
|
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
|
|
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
|
|
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
|
|
|
|
// Freshly sequenced number from the same series
|
|
expect(typeof storno.invoice_number).toBe('string');
|
|
expect(storno.invoice_number.length).toBeGreaterThan(0);
|
|
expect(storno.invoice_number).not.toBe(original.invoice_number);
|
|
|
|
// Line items snapshotted onto the Storno
|
|
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
|
|
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
|
|
expect(stornoItems).toHaveLength(originalItems.length);
|
|
|
|
// Original flipped + back-linked
|
|
const refreshed = await db('invoices').where({ id: original.id }).first();
|
|
expect(refreshed.status).toBe('cancelled');
|
|
expect(refreshed.cancellation_storno_id).toBe(storno.id);
|
|
|
|
// sendStorno side effects (codex review of #850): cancelInvoice
|
|
// swallows a sendStorno failure by design, so without these
|
|
// assertions a broken render/persist/queue leg would stay green.
|
|
const sentStorno = await db('invoices').where({ id: storno.id }).first();
|
|
expect(sentStorno.status).toBe('sent');
|
|
expect(sentStorno.pdf_path).toBeTruthy();
|
|
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
|
|
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
|
|
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
|
|
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
|
});
|
|
|
|
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
|
|
const original = await seedIssuedInvoice('paid');
|
|
|
|
const res = await request(invoiceApp)
|
|
.post(`/api/admin/invoices/${original.id}/cancel`)
|
|
.set(auth);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stornoId).toBeGreaterThan(0);
|
|
|
|
const refreshed = await db('invoices').where({ id: original.id }).first();
|
|
expect(refreshed.status).toBe('cancelled');
|
|
});
|
|
|
|
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
|
|
const original = await seedIssuedInvoice('sent');
|
|
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
|
|
|
|
const res = await request(invoiceApp)
|
|
.post(`/api/admin/invoices/${original.id}/cancel`)
|
|
.set(auth);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('ALREADY_CANCELLED');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/contracts/:id/countersign', () => {
|
|
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
|
|
const contract = await seedCustomerSignedContract();
|
|
await db('email_queue').del();
|
|
|
|
const res = await request(contractApp)
|
|
.post(`/api/admin/contracts/${contract.id}/countersign`)
|
|
.set(auth)
|
|
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('fully_signed');
|
|
|
|
const row = await db('contracts').where({ id: contract.id }).first();
|
|
expect(row.status).toBe('fully_signed');
|
|
expect(row.signed_admin_name).toBe('Admin Tester');
|
|
expect(row.signed_by_admin_at).toBeTruthy();
|
|
|
|
// The customer's own signature (from the real sign flow in the seed)
|
|
// must survive countersigning — layered, not replaced.
|
|
expect(row.signed_customer_signature_path).toBeTruthy();
|
|
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
|
|
expect(row.signed_customer_name).toBe('Custo Mer');
|
|
|
|
// Admin signature image persisted under the storage root
|
|
expect(row.signed_admin_signature_path).toBeTruthy();
|
|
expect(row.signed_admin_signature_path.startsWith(
|
|
path.join(storageRoot, 'contract', 'signatures'),
|
|
)).toBe(true);
|
|
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
|
|
|
|
// Stamped, fully-signed PDF written and hashed. The issue spec
|
|
// called this `integrity_hash`; the real column is
|
|
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
|
|
expect(row.signed_pdf_render_failed_at).toBeFalsy();
|
|
expect(row.signed_pdf_path).toBeTruthy();
|
|
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
|
|
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
|
|
|
|
// BOTH stamps must be embedded in the final document — a regression
|
|
// stamping the admin onto the unsigned base PDF would keep every
|
|
// path/hash assertion above green (codex review of #850 round 2).
|
|
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
|
|
const maxImagesOnAPage = Math.max(...imagesPerPage);
|
|
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
|
|
|
|
// contract_fully_signed email to the customer's primary address,
|
|
// carrying the signed PDF as attachment (plus the audit cert).
|
|
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
|
|
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
|
|
expect(customerCopy).toBeTruthy();
|
|
const emailData = JSON.parse(customerCopy.email_data);
|
|
expect(emailData.contract_number).toBe(contract.contract_number);
|
|
expect(Array.isArray(emailData.attachments)).toBe(true);
|
|
const pdfAttachment = emailData.attachments.find(
|
|
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
|
|
);
|
|
expect(pdfAttachment).toBeTruthy();
|
|
expect(pdfAttachment.contentType).toBe('application/pdf');
|
|
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
|
|
});
|
|
|
|
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
|
|
const draftId = await contractService.createContract({
|
|
customerAccountId: customerId,
|
|
title: 'Noch nicht versendet',
|
|
}, adminId);
|
|
|
|
const res = await request(contractApp)
|
|
.post(`/api/admin/contracts/${draftId}/countersign`)
|
|
.set(auth)
|
|
.send({ name: 'Admin Tester' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
|
|
});
|
|
});
|