a6a3c9f9f8
* fix(crm): pass trx to logActivity inside transactions — audit rows were silently lost on SQLite createContract, updateContract, createStorno and reissueInvoice called logActivity() (contract paths also adminActor()) from inside a knex transaction without the trx executor — the pattern db.js:648's comment explicitly warns about. On single-connection SQLite the audit insert waits on a second pool connection while the trx holds the only one: a 60s acquire-timeout stall per call, then logActivity's catch swallows the failure and the audit row is silently lost. Postgres unaffected. Fix mirrors the one call site that already did it right (contract_created_from_quote, conversions.js): resolve the audit actor before the transaction opens and pass trx as logActivity's executor so the insert rides the transaction's connection. Verified NOT affected (logActivity outside any trx, unchanged): cancelContract, contract_converted_to_event, contract_signed_by_customer, contract_sent, invoice_sent/_cancelled(draft)/_released/monthly_bill. Found by the #587 integration-test work (PR #850, which shrank the pool acquire timeout to tolerate the stall — that workaround can be dropped once both land). * fix(crm): run reissueInvoice's createInvoice without a wrapping transaction (codex review of #851) The round-1 fix passed trx to the reissue audit call — but that point was never reached on single-connection SQLite: createInvoice internally reads via the global connection (businessProfileService.getProfile, getAppSetting, bank-account resolution), so the outer trx deadlocked first and aborted the replacement AFTER the Storno had already committed and been emailed. createInvoice's five other callers all run it without a trx; reissue now does the same and backlinks afterwards. Trade-off documented in code: replacement + backlink are no longer atomic — a crash between them leaves a visible draft without replaces_invoice_id, which beats the guaranteed stall. New regression test drives a full cancel+reissue on the SQLite harness and pins the invoice_reissued audit row. * fix(crm): restore the reissue transaction by routing createInvoice's reads through trx (codex review of #851, round 2) Round 2 was right that dropping the wrapping transaction traded the deadlock for orphan drafts: createInvoice inserts the invoice row and claims a sequence number BEFORE line-item validation can throw, so a failed reissue would persist partial state after the Storno committed. Proper fix: the transaction is back, and every read inside createInvoice now rides it — getProfile and resolveBankAccountForCurrency gained an optional conn param (default db, all other callers unchanged), getAppSetting calls pass trx (crm_invoice_round_total + the resolveNetDays default the regression test flushed out), and the invoice_created audit uses the trx executor. The reissue regression test now proves a full cancel+reissue commits atomically on single-connection SQLite.
156 lines
5.6 KiB
JavaScript
156 lines
5.6 KiB
JavaScript
/**
|
|
* Regression tests for logActivity calls inside transactions (#850 review
|
|
* find). createContract / updateContract / createStorno / reissueInvoice
|
|
* called logActivity() (and contract paths also adminActor()) from inside
|
|
* a knex transaction WITHOUT the trx executor. On single-connection SQLite
|
|
* the audit insert then waits on a second pool connection while the trx
|
|
* holds the only one — a 60s acquire-timeout stall per call, after which
|
|
* logActivity's catch swallows the failure and the audit row is silently
|
|
* lost. Postgres was unaffected.
|
|
*
|
|
* The observable fix: the activity_logs rows now exist, and the calls
|
|
* complete without waiting on the pool. The shrunken acquire timeout
|
|
* below makes any reintroduced deadlock fail the test quickly instead
|
|
* of appearing to pass after a long stall.
|
|
*/
|
|
const path = require('path');
|
|
const {
|
|
bootCrmDb, seedMinimal, assignAdminRole,
|
|
} = require('../integration/helpers/crmDb');
|
|
|
|
jest.setTimeout(120000);
|
|
|
|
let db;
|
|
let cleanup;
|
|
let tmpDir;
|
|
let adminId;
|
|
let customerId;
|
|
let contractService;
|
|
let invoiceService;
|
|
|
|
const prevCwd = process.cwd();
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup, tmpDir } = await bootCrmDb());
|
|
// Business-doc artifacts land under process.cwd()/storage — isolate.
|
|
process.chdir(tmpDir);
|
|
|
|
// A reintroduced in-trx pool grab should fail fast (2s), not stall 60s.
|
|
db.client.pool.acquireTimeoutMillis = 2000;
|
|
|
|
// node-sqlite3 detects Date bindings via the NATIVE realm's Date —
|
|
// under jest's vm sandbox that check fails and Dates stringify to
|
|
// "[object Object]". Normalize to ISO strings on the client prototype
|
|
// (transaction clients are Object.create()d from it). Same shim as
|
|
// crmMintPaths.test.js.
|
|
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');
|
|
|
|
contractService = require('../../src/services/contractService');
|
|
invoiceService = require('../../src/services/invoiceService');
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
process.chdir(prevCwd);
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
test('createContract persists the contract_created audit row (was silently lost on SQLite)', async () => {
|
|
const contractId = await contractService.createContract({
|
|
customerAccountId: customerId,
|
|
title: 'Audit-Trail-Vertrag',
|
|
}, adminId);
|
|
|
|
const row = await db('activity_logs')
|
|
.where({ activity_type: 'contract_created' })
|
|
.orderBy('id', 'desc')
|
|
.first();
|
|
expect(row).toBeTruthy();
|
|
expect(JSON.parse(row.metadata).contractId).toBe(contractId);
|
|
expect(row.actor_type).toBe('admin');
|
|
});
|
|
|
|
test('updateContract persists the contract_updated audit row', async () => {
|
|
const contractId = await contractService.createContract({
|
|
customerAccountId: customerId,
|
|
title: 'Vorher',
|
|
}, adminId);
|
|
|
|
await contractService.updateContract(contractId, { title: 'Nachher' }, adminId);
|
|
|
|
const row = await db('activity_logs')
|
|
.where({ activity_type: 'contract_updated' })
|
|
.orderBy('id', 'desc')
|
|
.first();
|
|
expect(row).toBeTruthy();
|
|
expect(JSON.parse(row.metadata).contractId).toBe(contractId);
|
|
});
|
|
|
|
test('cancelInvoice (Storno mint) persists the invoice_cancelled_via_storno audit row', async () => {
|
|
const { invoiceIds } = await invoiceService.createInvoice({
|
|
customerAccountId: customerId,
|
|
currency: 'CHF',
|
|
vatRate: 0,
|
|
lineItems: [
|
|
{ position: 1, quantity: 1, description: 'Coverage', unit_price_minor: 100000, discount_percent: 0 },
|
|
],
|
|
}, adminId);
|
|
const id = invoiceIds[0];
|
|
await db('invoices').where({ id }).update({ status: 'sent', sent_at: new Date(), updated_at: new Date() });
|
|
|
|
const result = await invoiceService.cancelInvoice(id, adminId);
|
|
expect(result.cancelled).toBe(true);
|
|
|
|
const row = await db('activity_logs')
|
|
.where({ activity_type: 'invoice_cancelled_via_storno' })
|
|
.orderBy('id', 'desc')
|
|
.first();
|
|
expect(row).toBeTruthy();
|
|
const meta = JSON.parse(row.metadata);
|
|
expect(meta.invoiceId).toBe(id);
|
|
expect(meta.stornoId).toBe(result.stornoId);
|
|
});
|
|
|
|
test('reissueInvoice completes on SQLite and persists the invoice_reissued audit row', async () => {
|
|
const { invoiceIds } = await invoiceService.createInvoice({
|
|
customerAccountId: customerId,
|
|
currency: 'CHF',
|
|
vatRate: 0,
|
|
lineItems: [
|
|
{ position: 1, quantity: 1, description: 'Album', unit_price_minor: 50000, discount_percent: 0 },
|
|
],
|
|
}, adminId);
|
|
const id = invoiceIds[0];
|
|
await db('invoices').where({ id }).update({ status: 'sent', sent_at: new Date(), updated_at: new Date() });
|
|
|
|
// Pre-fix this stalled inside the wrapping transaction (createInvoice's
|
|
// global-connection reads vs. the single-connection pool) and aborted
|
|
// before the replacement existed — with the Storno already committed.
|
|
const result = await invoiceService.reissueInvoice(id, adminId);
|
|
expect(result.id).toBeGreaterThan(0);
|
|
expect(result.replaces).toBe(id);
|
|
|
|
const replacement = await db('invoices').where({ id: result.id }).first();
|
|
expect(replacement.replaces_invoice_id).toBe(id);
|
|
|
|
const row = await db('activity_logs')
|
|
.where({ activity_type: 'invoice_reissued' })
|
|
.orderBy('id', 'desc')
|
|
.first();
|
|
expect(row).toBeTruthy();
|
|
expect(JSON.parse(row.metadata).newInvoiceId).toBe(result.id);
|
|
});
|
|
|
|
void path; // referenced for parity with sibling suites
|