40eb03f0d8
The 3.94.0-beta.0 release PR (#859) failed its backend job on workflowEngine.test.js: bootCrmDb() runs every core migration in beforeAll, and with migrations 163-165 merged the setup crossed the suite's jest.setTimeout(30000) on CI runners — the log shows migration 099 still seeding after the hook timed out. Same pass is green locally and passed on #857's rebase minutes earlier: borderline-slow, not deterministic. - jest.config.js: testTimeout 120000 as the default, so bootCrmDb suites without an explicit pin stop being time bombs as the chain grows - every suite-level jest.setTimeout below 120s raised to 120s — local pins OVERRIDE the config default, so the 30s/60s ones would keep flaking regardless of the global bump No test logic changed anywhere. Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
171 lines
8.2 KiB
JavaScript
171 lines
8.2 KiB
JavaScript
/**
|
|
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
|
|
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
|
|
* never auto-sends them before the workflow's review gate + explicit
|
|
* send_document.
|
|
*/
|
|
const crypto = require('crypto');
|
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
|
|
|
jest.setTimeout(120000);
|
|
|
|
describe('booking cutover — draft invoices on hold', () => {
|
|
let db; let cleanup; let adminId; let customerId; let quoteService;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
({ adminId, customerId } = await seedMinimal(db));
|
|
quoteService = require('../../src/services/quoteService');
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
async function acceptedQuote() {
|
|
const dealUuid = crypto.randomUUID();
|
|
const [id] = await db('quotes').insert({
|
|
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
|
customer_account_id: customerId,
|
|
status: 'accepted',
|
|
currency: 'CHF',
|
|
issue_date: '2026-01-01',
|
|
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
|
|
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
|
|
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
|
|
deal_uuid: dealUuid,
|
|
created_by_admin_id: adminId,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
|
|
const quoteId = await acceptedQuote();
|
|
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
|
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
|
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
|
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
|
|
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
|
|
});
|
|
|
|
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
|
|
const quoteId = await acceptedQuote();
|
|
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
|
|
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
|
expect(inv.status).toBe('scheduled');
|
|
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
|
|
});
|
|
|
|
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
|
|
const quoteId = await acceptedQuote();
|
|
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
|
|
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
|
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
|
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const ev = await db('events').where({ id: res.eventId }).first();
|
|
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
|
|
|
|
// Every invoice the event scheduled is held (no auto-send before the gate).
|
|
const invs = await db('invoices').whereIn('id', res.invoiceIds);
|
|
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
|
|
|
|
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
|
|
// again for it (the flow's prepare_invoice adopts these ids instead).
|
|
const q = await db('quotes').where({ id: quoteId }).first();
|
|
expect(q.converted_event_id).toBe(res.eventId);
|
|
});
|
|
|
|
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
|
|
// Reproduces the booking_invoice_only flow on a quote with no explicit
|
|
// payment timing: the default installment is after_delivery, which would
|
|
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
|
|
const dealUuid = crypto.randomUUID();
|
|
const [quoteId] = await db('quotes').insert({
|
|
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
|
customer_account_id: customerId,
|
|
status: 'accepted',
|
|
currency: 'CHF',
|
|
issue_date: '2026-01-01',
|
|
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
|
|
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
|
|
// 100% after_delivery installment.
|
|
deal_uuid: dealUuid,
|
|
created_by_admin_id: adminId,
|
|
});
|
|
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
|
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
|
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
|
|
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
|
});
|
|
|
|
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
|
|
const mk = async (lockOffsetMs) => {
|
|
const dealUuid = crypto.randomUUID();
|
|
const [id] = await db('quotes').insert({
|
|
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
|
customer_account_id: customerId,
|
|
status: 'accepted',
|
|
currency: 'CHF', issue_date: '2026-01-01',
|
|
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
|
|
responded_at: new Date().toISOString(),
|
|
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
|
|
accepted_at: new Date().toISOString(),
|
|
deal_uuid: dealUuid,
|
|
created_by_admin_id: adminId,
|
|
});
|
|
return id;
|
|
};
|
|
const openId = await mk(15 * 60 * 1000); // still inside the window
|
|
const lockedId = await mk(-60 * 1000); // window already closed
|
|
|
|
const emitted = await quoteService.finalizeQuoteResponses();
|
|
expect(emitted).toBeGreaterThanOrEqual(1);
|
|
|
|
const open = await db('quotes').where({ id: openId }).first();
|
|
const locked = await db('quotes').where({ id: lockedId }).first();
|
|
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
|
|
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
|
|
|
|
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
|
|
const again = await db('quotes').where({ id: lockedId })
|
|
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
|
|
expect(again).toBe(0);
|
|
});
|
|
|
|
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
|
const quoteId = await acceptedQuote();
|
|
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
|
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
|
expect(res.invoiceIds).toEqual([]);
|
|
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
|
|
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
|
|
});
|
|
|
|
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
|
|
const quoteId = await acceptedQuote();
|
|
const newId = await quoteService.duplicateQuote(quoteId, adminId);
|
|
expect(newId).toBeGreaterThanOrEqual(1);
|
|
expect(newId).not.toBe(quoteId);
|
|
const q = await db('quotes').where({ id: newId }).first();
|
|
expect(q.status).toBe('draft');
|
|
});
|
|
|
|
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
|
|
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
|
|
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
|
|
expect(typeof registry.getAction(a)).toBe('function');
|
|
}
|
|
});
|
|
|
|
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
|
|
const contractService = require('../../src/services/contractService');
|
|
const quoteId = await acceptedQuote();
|
|
const res = await contractService.createFromQuote(quoteId, adminId);
|
|
expect(res.contractId).toBeGreaterThanOrEqual(1);
|
|
expect(res.alreadyConverted).toBe(false);
|
|
const c = await db('contracts').where({ id: res.contractId }).first();
|
|
expect(c).toBeTruthy();
|
|
});
|
|
});
|