godcrm/backend/services/__tests__/tableMutationService.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

309 lines
12 KiB
JavaScript

// 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: <title>'`, `space_id`, and the actor as participant.
// Manual Discuss path uses `ensureRowChat`.
const CRITERION_STATUS_RULE = {
id: 12, table_id: 7256, column_key: 'status',
template:
"{% if new.status == 'failed' %}🔴 Regressed: was passing, now failing.\n" +
"Last green run: {{old.claimed_at | default: 'unknown'}}. Trigger: {{new.failed_test_id | default: 'manual flip'}}.\n" +
"[Discuss → spawn ticket]{% elsif new.status == 'verified' %}✅ Verified ({{old.status}} → verified)" +
"{% else %}🔖 {{old.status}} → {{new.status}}{% endif %}",
event_type: 'state_change', enabled: true,
};
function setupCriterionRegression(existingConv = null) {
dbAllMock.mockReset();
dbGetMock.mockReset();
dbRunMock.mockReset();
dbAllMock.mockImplementation((sql) => {
if (/_chat_mutation_log_config/.test(sql)) {
return Promise.resolve([CRITERION_STATUS_RULE]);
}
return Promise.resolve([]);
});
dbGetMock.mockImplementation((sql, _params) => {
if (/universal_tables/.test(sql) && /space_id/.test(sql)) {
return Promise.resolve({ space_id: 11 });
}
if (/table_columns/.test(sql)) {
return Promise.resolve({ id: 999, type: 'select', config: null });
}
if (/FROM table_rows/.test(sql)) {
return Promise.resolve({ data: JSON.stringify({ title: 'Verify deploy script' }) });
}
if (/FROM conversations/.test(sql)) {
return Promise.resolve(existingConv); // null → triggers create
}
return Promise.resolve(null);
});
// Distinguish dbRun call types so we can return a fresh ID for the conv insert
// and a benign ack for everything else.
dbRunMock.mockImplementation((sql) => {
if (/INSERT INTO conversations/.test(sql)) {
return Promise.resolve({ lastInsertRowid: 7777 });
}
return Promise.resolve({ lastInsertRowid: null });
});
}
describe('tableMutationService.emitRowMutationEvents — P4 lazy criterion chat', () => {
beforeEach(() => {
process.env.ROW_MUTATION_LOG_ENABLED_SPACES = '11';
});
it('6) criterion status passed→failed: lazy-creates conv with title+space_id+participant', async () => {
setupCriterionRegression(null);
const { emitRowMutationEvents } = await loadModule();
await emitRowMutationEvents({
tableId: 7256,
rowId: 555,
oldData: { status: 'passed', title: 'Verify deploy script', claimed_at: '2026-05-01' },
newData: { status: 'failed', title: 'Verify deploy script', failed_test_id: 42 },
actor: { id: 17, name: 'ralph' },
});
const convInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversations/.test(sql));
expect(convInserts).toHaveLength(1);
const [, convParams] = convInserts[0];
// title (1st), space_id (2nd), bound_table_id (3rd), bound_row_id (4th), actor (5th)
expect(convParams[0]).toBe('Criterion: Verify deploy script');
expect(convParams[1]).toBe(11);
expect(convParams[2]).toBe(7256);
expect(convParams[3]).toBe(555);
expect(convParams[4]).toBe(17);
const partInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversation_participants/.test(sql));
expect(partInserts.length).toBeGreaterThanOrEqual(1);
const msgInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql));
expect(msgInserts).toHaveLength(1);
expect(msgInserts[0][1][2]).toContain('🔴 Regressed');
expect(msgInserts[0][1][2]).toContain('was passing, now failing');
expect(msgInserts[0][1][2]).toContain('[Discuss → spawn ticket]');
});
it('7) idempotency: second regression on same criterion reuses existing conv (no new INSERT)', async () => {
setupCriterionRegression({ id: 7777 });
const { emitRowMutationEvents } = await loadModule();
await emitRowMutationEvents({
tableId: 7256,
rowId: 555,
oldData: { status: 'verified' },
newData: { status: 'failed', title: 'Verify deploy script' },
actor: { id: 17, name: 'ralph' },
});
const convInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversations/.test(sql));
expect(convInserts).toHaveLength(0);
const msgInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO messages/.test(sql));
expect(msgInserts).toHaveLength(1);
expect(msgInserts[0][1][0]).toBe(7777); // posted into existing conv
expect(msgInserts[0][1][2]).toContain('🔴 Regressed');
});
it('8) ensureRowChat: creates new chat with criterion title hint when none exists', async () => {
setupCriterionRegression(null);
const { ensureRowChat } = await loadModule();
const conv = await ensureRowChat({ tableId: 7256, rowId: 555, actorId: 17 });
expect(conv?.id).toBe(7777);
const convInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversations/.test(sql));
expect(convInserts).toHaveLength(1);
expect(convInserts[0][1][0]).toBe('Criterion: Verify deploy script');
expect(convInserts[0][1][1]).toBe(11);
});
it('9) ensureRowChat: idempotent — existing chat returned without INSERT', async () => {
setupCriterionRegression({ id: 7777 });
const { ensureRowChat } = await loadModule();
const conv = await ensureRowChat({ tableId: 7256, rowId: 555, actorId: 17 });
expect(conv?.id).toBe(7777);
const convInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversations/.test(sql));
expect(convInserts).toHaveLength(0);
// Participant must still be ensured for the visiting user.
const partInserts = dbRunMock.mock.calls.filter(([sql]) => /INSERT INTO conversation_participants/.test(sql));
expect(partInserts.length).toBeGreaterThanOrEqual(1);
});
});