// ADR-0031 P1 — tableMutationService tests // // Covers the four acceptance cases from T-140314: // 1. Single column change → 1 system message // 2. Two column changes → 2 system messages // 3. Feature flag OFF → 0 messages // 4. suppress_mutation_log → 0 messages // // We mock the database connection module so the test does not touch the test // DB and works without seeded fixtures. The diff + render + emit pipeline is // what we actually want to verify. import { describe, it, expect, beforeEach, vi } from 'vitest'; const dbAllMock = vi.fn(); const dbGetMock = vi.fn(); const dbRunMock = vi.fn(); vi.mock('../../database/connection.js', () => ({ dbAll: (...args) => dbAllMock(...args), dbGet: (...args) => dbGetMock(...args), dbRun: (...args) => dbRunMock(...args), sqlNow: () => `'2026-05-05T00:00:00Z'`, safeJsonParse: (v, d = null) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } }, })); vi.mock('../../utils/logger.js', () => ({ apiLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const STATE_RULE = { id: 1, table_id: 1708, column_key: 'state', template: '🔄 {{display.old}} → {{display.new}}', event_type: 'state_change', enabled: true, }; const PROGRESS_RULE = { id: 2, table_id: 1708, column_key: 'progress', template: '📊 {{old.progress | default: 0}}% → {{new.progress}}%', event_type: 'progress', enabled: true, }; function setupHappyPath() { dbAllMock.mockReset(); dbGetMock.mockReset(); dbRunMock.mockReset(); // 1) loadConfig() — enabled rules dbAllMock.mockImplementation((sql) => { if (/_chat_mutation_log_config/.test(sql)) { return Promise.resolve([STATE_RULE, PROGRESS_RULE]); } return Promise.resolve([]); }); // 2) getTableSpaceId / getColumnConfig / conversation lookup / display resolution dbGetMock.mockImplementation((sql, params) => { if (/universal_tables/.test(sql) && /space_id/.test(sql)) { return Promise.resolve({ space_id: 11 }); } if (/table_columns/.test(sql)) { // Non-relation column returns no config → display falls back to raw value. return Promise.resolve({ id: 999, type: 'text', config: null }); } if (/FROM conversations/.test(sql)) { return Promise.resolve({ id: 4242 }); // existing chat } return Promise.resolve(null); }); // 3) INSERT messages — captured, no real run dbRunMock.mockResolvedValue({ lastInsertRowid: 9001 }); } async function loadModule() { // Re-import so the env var is picked up freshly. const mod = await import('../tableMutationService.js?t=' + Math.random()); mod.invalidateMutationConfigCache(); return mod; } describe('tableMutationService.emitRowMutationEvents (ADR-0031 P1)', () => { beforeEach(() => { process.env.ROW_MUTATION_LOG_ENABLED_SPACES = '11'; setupHappyPath(); }); it('1) emits exactly 1 system message when one configured column changes', async () => { const { emitRowMutationEvents } = await loadModule(); await emitRowMutationEvents({ tableId: 1708, rowId: 100, oldData: { state: 1, progress: 50 }, newData: { state: 2, progress: 50 }, actor: { id: 1, name: 'tester' }, }); const inserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql)); expect(inserts).toHaveLength(1); const [, params] = inserts[0]; expect(params[0]).toBe(4242); // conversation_id expect(params[1]).toBe(1); // sender_id (actor) expect(params[2]).toContain('🔄'); // rendered content const metadata = JSON.parse(params[3]); expect(metadata.event_type).toBe('state_change'); expect(metadata.column_key).toBe('state'); expect(metadata.old).toBe(1); expect(metadata.new).toBe(2); expect(metadata.row_ref).toMatchObject({ table_id: 1708, row_id: 100 }); }); it('2) emits exactly 2 system messages when two configured columns change', async () => { const { emitRowMutationEvents } = await loadModule(); await emitRowMutationEvents({ tableId: 1708, rowId: 100, oldData: { state: 1, progress: 0 }, newData: { state: 2, progress: 75 }, actor: { id: 1, name: 'tester' }, }); const inserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql)); expect(inserts).toHaveLength(2); const eventTypes = inserts.map(([, params]) => JSON.parse(params[3]).event_type).sort(); expect(eventTypes).toEqual(['progress', 'state_change']); // progress template uses default: 0 — verify rendering produced a string with "0%" const progressInsert = inserts.find(([, params]) => JSON.parse(params[3]).column_key === 'progress'); expect(progressInsert[1][2]).toContain('0%'); expect(progressInsert[1][2]).toContain('75%'); }); it('3) emits 0 messages when feature flag is OFF (no spaces in env var)', async () => { process.env.ROW_MUTATION_LOG_ENABLED_SPACES = ''; const { emitRowMutationEvents } = await loadModule(); await emitRowMutationEvents({ tableId: 1708, rowId: 100, oldData: { state: 1 }, newData: { state: 2 }, actor: { id: 1, name: 'tester' }, }); const inserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql)); expect(inserts).toHaveLength(0); }); it('4) emits 0 messages when ctx.suppress_mutation_log = true', async () => { const { emitRowMutationEvents } = await loadModule(); await emitRowMutationEvents({ tableId: 1708, rowId: 100, oldData: { state: 1 }, newData: { state: 2 }, actor: { id: 1, name: 'tester' }, ctx: { suppress_mutation_log: true }, }); const inserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql)); expect(inserts).toHaveLength(0); }); it('5) bonus: skips updated_at/created_at even if they differ (defence-in-depth)', async () => { const { emitRowMutationEvents } = await loadModule(); await emitRowMutationEvents({ tableId: 1708, rowId: 100, oldData: { state: 1, updated_at: '2026-05-04', created_at: '2026-05-01' }, newData: { state: 1, updated_at: '2026-05-05', created_at: '2026-05-01' }, actor: { id: 1, name: 'tester' }, }); const inserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql)); expect(inserts).toHaveLength(0); // state unchanged + updated_at excluded }); }); // ADR-0031 P4 — lazy criterion chat + ensureRowChat helper. // New surface: criterion regression auto-creates a row-bound chat with // `title='Criterion: