diff --git a/backend/__tests__/integration/crmSchema.test.js b/backend/__tests__/integration/crmSchema.test.js new file mode 100644 index 00000000..fde94cad --- /dev/null +++ b/backend/__tests__/integration/crmSchema.test.js @@ -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('tester@example.com'); + expect(customer.email).toBe('customer@example.com'); + }); + }); +}); diff --git a/backend/__tests__/integration/helpers/crmDb.js b/backend/__tests__/integration/helpers/crmDb.js new file mode 100644 index 00000000..ba51f9e6 --- /dev/null +++ b/backend/__tests__/integration/helpers/crmDb.js @@ -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: 'tester@example.com', + 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: 'customer@example.com', + 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 };