test(crm): integration harness + schema-shape regression net
Adds two pieces:
- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
DB by invoking every migrations/core/*.up() directly. Bypasses
knex's Migrator because its exclusive write lock deadlocks
001_init's nested initializeDatabase() call. ~1 second cold start.
- __tests__/integration/crmSchema.test.js — 36 assertions on the
table + column layout after the consolidated CRM migration runs.
Pins:
- every CRM table present (quotes, contracts, invoices + the
eight supporting tables)
- deal_uuid columns on all three lineage tables (the column
DocumentLineageCard joins on — drop it anywhere and the card
silently returns partial data)
- back-pointer FKs (converted_contract_id, source_contract_id,
source_quote_id) — the exact columns that triggered the
Postgres FK-ordering bug fixed earlier in this PR
- Storno discriminator (kind, cancels_invoice_id, replaces_
invoice_id) per feedback_storno_filter_everywhere
- event time columns from migration 137
A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Schema-shape regression net for the CRM consolidated migration.
|
||||||
|
*
|
||||||
|
* Pins the table/column layout that the route + service layer expect
|
||||||
|
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
|
||||||
|
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
|
||||||
|
* forward-reference deferral added in this PR), but it doesn't notice
|
||||||
|
* if a future edit silently drops a column the service code reads —
|
||||||
|
* SQLite would just return undefined and the broken behavior would
|
||||||
|
* land on beta.
|
||||||
|
*
|
||||||
|
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
|
||||||
|
* so a rename or removal there fails the test instead of silently
|
||||||
|
* breaking the lineage card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||||
|
|
||||||
|
describe('CRM schema after core migrations', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('table layout', () => {
|
||||||
|
const expectedTables = [
|
||||||
|
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
|
||||||
|
'events', 'document_sequences',
|
||||||
|
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
|
||||||
|
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
|
||||||
|
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
|
||||||
|
'customer_hour_entries',
|
||||||
|
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
|
||||||
|
'event_payment_plans',
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(expectedTables)('has table %s', async (table) => {
|
||||||
|
expect(await db.schema.hasTable(table)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deal_uuid lineage columns', () => {
|
||||||
|
// Every document in one engagement shares a deal_uuid — the
|
||||||
|
// lineage card joins on it. Drop the column anywhere in the chain
|
||||||
|
// and the card silently returns partial data.
|
||||||
|
it.each(['quotes', 'contracts', 'invoices'])(
|
||||||
|
'%s has deal_uuid column',
|
||||||
|
async (table) => {
|
||||||
|
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// The back-pointer FKs were the source of the schema-drift bug
|
||||||
|
// we fixed in this PR (forward references). Pin them.
|
||||||
|
it('quotes has converted_contract_id back-pointer', async () => {
|
||||||
|
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
|
||||||
|
});
|
||||||
|
it('invoices has source_contract_id back-pointer', async () => {
|
||||||
|
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
|
||||||
|
});
|
||||||
|
it('invoices has source_quote_id back-pointer', async () => {
|
||||||
|
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Storno discriminator columns', () => {
|
||||||
|
// kind='storno' + cancels_invoice_id + negative totals are the
|
||||||
|
// shape every aggregate filter relies on (feedback_storno_filter_
|
||||||
|
// everywhere). Pin the columns so a rename doesn't silently break
|
||||||
|
// every revenue report.
|
||||||
|
it('invoices has kind discriminator', async () => {
|
||||||
|
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
|
||||||
|
});
|
||||||
|
it('invoices has cancels_invoice_id self-ref', async () => {
|
||||||
|
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
|
||||||
|
});
|
||||||
|
it('invoices has replaces_invoice_id self-ref', async () => {
|
||||||
|
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Event time columns (migration 137)', () => {
|
||||||
|
// The admin calendar reads these to render timed vs. full-day
|
||||||
|
// tiles. Per the feedback_migration_preserve_visuals rule, the
|
||||||
|
// default has to be `is_full_day=true` so existing rows keep
|
||||||
|
// their pre-migration visual.
|
||||||
|
it('events has event_time_start', async () => {
|
||||||
|
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
|
||||||
|
});
|
||||||
|
it('events has event_time_end', async () => {
|
||||||
|
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
|
||||||
|
});
|
||||||
|
it('events has is_full_day', async () => {
|
||||||
|
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seed paths', () => {
|
||||||
|
it('admin + customer seed inserts cleanly', async () => {
|
||||||
|
const { adminId, customerId } = await seedMinimal(db);
|
||||||
|
expect(adminId).toBeTruthy();
|
||||||
|
expect(customerId).toBeTruthy();
|
||||||
|
|
||||||
|
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||||
|
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||||
|
expect(admin.email).toBe('[email protected]');
|
||||||
|
expect(customer.email).toBe('[email protected]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Test harness for CRM integration tests.
|
||||||
|
*
|
||||||
|
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
|
||||||
|
* directly (bypassing knex's Migrator — its exclusive write lock
|
||||||
|
* deadlocks 001_init's nested `initializeDatabase()` call), and
|
||||||
|
* exposes a small helper for seeding the minimal row set that the
|
||||||
|
* quote/contract/invoice services need to operate.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||||
|
*
|
||||||
|
* beforeAll(async () => {
|
||||||
|
* ({ db, cleanup } = await bootCrmDb());
|
||||||
|
* ({ adminId, customerId } = await seedMinimal(db));
|
||||||
|
* });
|
||||||
|
* afterAll(async () => { await cleanup(); });
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
async function runCoreMigrations(db) {
|
||||||
|
await db.schema.createTable('migrations', (t) => {
|
||||||
|
t.increments('id').primary();
|
||||||
|
t.string('filename').unique().notNullable();
|
||||||
|
t.timestamp('applied_at').defaultTo(db.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
|
||||||
|
const files = (await fs.promises.readdir(coreDir))
|
||||||
|
.filter((f) => f.endsWith('.js'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const mod = require(path.join(coreDir, f));
|
||||||
|
if (typeof mod.up === 'function') {
|
||||||
|
await mod.up(db);
|
||||||
|
}
|
||||||
|
await db('migrations').insert({ filename: f });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
|
||||||
|
* Caller must invoke cleanup() in afterAll to release the SQLite file
|
||||||
|
* and the temp directory.
|
||||||
|
*/
|
||||||
|
async function bootCrmDb() {
|
||||||
|
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
|
||||||
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||||
|
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||||
|
|
||||||
|
// No jest.resetModules() — every service the test later requires
|
||||||
|
// must share THIS db instance. Two module copies on one SQLite file
|
||||||
|
// each open their own knex pool and the SQLite write lock deadlocks
|
||||||
|
// the second one acquiring a connection. Caller is responsible for
|
||||||
|
// setting TEST_DATABASE_PATH before the first require of db.js
|
||||||
|
// (which knexfile reads at module-init time); bootCrmDb only works
|
||||||
|
// when invoked before any service import.
|
||||||
|
const { db } = require('../../../src/database/db');
|
||||||
|
|
||||||
|
await runCoreMigrations(db);
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
tmpDir,
|
||||||
|
cleanup: async () => {
|
||||||
|
try { await db.destroy(); } catch (_) {}
|
||||||
|
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the minimal row set that quote/contract/invoice services
|
||||||
|
* dereference on creation: an admin user, an active customer, a
|
||||||
|
* business_profile row, and the app_settings keys the services read.
|
||||||
|
*
|
||||||
|
* Returns the ids the caller will pass into service calls.
|
||||||
|
*/
|
||||||
|
async function seedMinimal(db) {
|
||||||
|
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
|
||||||
|
|
||||||
|
const adminInsert = await db('admin_users').insert({
|
||||||
|
username: 'tester', email: '[email protected]',
|
||||||
|
password_hash: passwordHash, must_change_password: false,
|
||||||
|
created_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
const adminId = adminInsert[0]?.id ?? adminInsert[0];
|
||||||
|
|
||||||
|
// business_profile is a singleton; the row is seeded by migration 107
|
||||||
|
// for fresh installs. Defensive: insert if missing.
|
||||||
|
const profile = await db('business_profile').first();
|
||||||
|
if (!profile) {
|
||||||
|
await db('business_profile').insert({
|
||||||
|
legal_name: 'Test Studio',
|
||||||
|
default_currency: 'CHF',
|
||||||
|
default_locale: 'de',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerInsert = await db('customer_accounts').insert({
|
||||||
|
email: '[email protected]',
|
||||||
|
display_name: 'Test Customer',
|
||||||
|
password_hash: passwordHash,
|
||||||
|
preferred_language: 'de',
|
||||||
|
is_active: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
}).returning('id');
|
||||||
|
const customerId = customerInsert[0]?.id ?? customerInsert[0];
|
||||||
|
|
||||||
|
return { adminId, customerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bootCrmDb, seedMinimal };
|
||||||
Reference in New Issue
Block a user