godcrm/backend/services/__tests__/criterionTicketSpawnService.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

219 lines
7.3 KiB
JavaScript

// ADR-0031 P6 — spawnTicketFromCriterion tests.
//
// Covers:
// 1. Happy path with auto-discovered messages (skips moved stubs)
// 2. Happy path with explicit message_ids
// 3. ticket_data validation (missing what / assigned_to)
// 4. source conversation not found
// 5. empty source conversation (no movable messages)
// 6. spawned_from links source bound row to new ticket
// 7. moveMessages failure surfaces upstream
import { describe, it, expect, beforeEach, vi } from 'vitest';
const dbGetMock = vi.fn();
const dbAllMock = vi.fn();
const dbRunMock = vi.fn();
const isPostgresMock = vi.fn(() => true);
const ensureRowChatMock = vi.fn();
const moveMessagesMock = vi.fn();
vi.mock('../../database/connection.js', () => ({
dbGet: (...args) => dbGetMock(...args),
dbAll: (...args) => dbAllMock(...args),
dbRun: (...args) => dbRunMock(...args),
isPostgres: () => isPostgresMock(),
}));
vi.mock('../tableMutationService.js', () => ({
ensureRowChat: (...args) => ensureRowChatMock(...args),
}));
vi.mock('../messageMoveService.js', () => ({
moveMessages: (...args) => moveMessagesMock(...args),
MoveValidationError: class extends Error {},
MoveAuthError: class extends Error {},
}));
vi.mock('../../utils/logger.js', () => ({
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('../../utils/baseId.js', () => ({
generateBaseId: () => 'TESTBASE',
}));
vi.mock('../chain-handoff/constants.js', () => ({
TICKETS_TABLE_ID: 1708,
}));
import { spawnTicketFromCriterion, SpawnValidationError } from '../criterionTicketSpawnService.js';
describe('spawnTicketFromCriterion — ADR-0031 P6', () => {
beforeEach(() => {
dbGetMock.mockReset();
dbAllMock.mockReset();
dbRunMock.mockReset();
ensureRowChatMock.mockReset();
moveMessagesMock.mockReset();
});
function mockSourceConversation(overrides = {}) {
dbGetMock.mockResolvedValueOnce({
id: 3001,
bound_table_id: 7256,
bound_row_id: 134220,
space_id: 11,
...overrides,
});
}
it('happy path: auto-discovers movable messages, creates ticket+chat, moves all', async () => {
mockSourceConversation();
dbAllMock.mockResolvedValueOnce([{ id: 501 }, { id: 502 }, { id: 503 }]);
dbRunMock.mockResolvedValueOnce({ rows: [{ id: 144000 }] });
ensureRowChatMock.mockResolvedValueOnce({ id: 4500 });
moveMessagesMock.mockResolvedValueOnce({
moved_count: 3,
source_message_ids: [501, 502, 503],
target_message_ids: [9001, 9002, 9003],
});
const result = await spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'Fix regression', why: 'criterion failed', assigned_to: 7 },
userId: 7,
});
expect(result.ticket_id).toBe(144000);
expect(result.ticket_conversation_id).toBe(4500);
expect(result.moved_count).toBe(3);
expect(result.spawned_from).toEqual({
table_id: 7256,
row_id: 134220,
conversation_id: 3001,
});
const insertSql = dbRunMock.mock.calls[0][0];
expect(insertSql).toMatch(/INSERT INTO table_rows/);
const insertParams = dbRunMock.mock.calls[0][1];
expect(insertParams[0]).toBe(1708);
expect(insertParams[1]).toBe('TESTBASE');
const persistedData = JSON.parse(insertParams[2]);
expect(persistedData.what).toBe('Fix regression');
expect(persistedData.spawned_from.row_id).toBe(134220);
expect(persistedData.state).toBe(24275);
expect(ensureRowChatMock).toHaveBeenCalledWith(expect.objectContaining({
tableId: 1708,
rowId: 144000,
actorId: 7,
}));
expect(moveMessagesMock).toHaveBeenCalledWith(expect.objectContaining({
sourceConversationId: 3001,
targetConversationId: 4500,
messageIds: [501, 502, 503],
actorIsChatOwner: true,
}));
});
it('uses explicit message_ids when provided, skipping auto-discovery', async () => {
mockSourceConversation();
dbRunMock.mockResolvedValueOnce({ rows: [{ id: 144001 }] });
ensureRowChatMock.mockResolvedValueOnce({ id: 4501 });
moveMessagesMock.mockResolvedValueOnce({
moved_count: 1,
source_message_ids: [502],
target_message_ids: [9100],
});
const result = await spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'subset only', assigned_to: 7 },
messageIds: [502],
userId: 7,
});
expect(dbAllMock).not.toHaveBeenCalled();
expect(moveMessagesMock.mock.calls[0][0].messageIds).toEqual([502]);
expect(result.moved_count).toBe(1);
});
it('rejects when ticket_data.what is missing', async () => {
await expect(spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { assigned_to: 7 },
userId: 7,
})).rejects.toBeInstanceOf(SpawnValidationError);
});
it('rejects when ticket_data.assigned_to is missing', async () => {
await expect(spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'no assignee' },
userId: 7,
})).rejects.toBeInstanceOf(SpawnValidationError);
});
it('rejects when source conversation does not exist', async () => {
dbGetMock.mockResolvedValueOnce(null);
await expect(spawnTicketFromCriterion({
sourceConversationId: 9999,
ticketData: { what: 'X', assigned_to: 7 },
userId: 7,
})).rejects.toBeInstanceOf(SpawnValidationError);
});
it('rejects when source conversation has no movable messages', async () => {
mockSourceConversation();
dbAllMock.mockResolvedValueOnce([]);
await expect(spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'X', assigned_to: 7 },
userId: 7,
})).rejects.toBeInstanceOf(SpawnValidationError);
});
it('surfaces moveMessages failure (ticket+chat already created — caller handles cleanup)', async () => {
mockSourceConversation();
dbAllMock.mockResolvedValueOnce([{ id: 501 }]);
dbRunMock.mockResolvedValueOnce({ rows: [{ id: 144002 }] });
ensureRowChatMock.mockResolvedValueOnce({ id: 4502 });
moveMessagesMock.mockRejectedValueOnce(new Error('database error during move'));
await expect(spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'fail in move', assigned_to: 7 },
userId: 7,
})).rejects.toThrow(/database error during move/);
expect(dbRunMock).toHaveBeenCalledTimes(1);
expect(ensureRowChatMock).toHaveBeenCalledTimes(1);
});
it('preserves source bound row info (criterion id 7256:X) in spawned_from on the ticket', async () => {
mockSourceConversation({ bound_table_id: 7256, bound_row_id: 999 });
dbAllMock.mockResolvedValueOnce([{ id: 700 }]);
dbRunMock.mockResolvedValueOnce({ rows: [{ id: 144003 }] });
ensureRowChatMock.mockResolvedValueOnce({ id: 4503 });
moveMessagesMock.mockResolvedValueOnce({
moved_count: 1,
source_message_ids: [700],
target_message_ids: [9200],
});
const result = await spawnTicketFromCriterion({
sourceConversationId: 3001,
ticketData: { what: 'criterion regression follow-up', assigned_to: 7 },
userId: 7,
});
const persistedData = JSON.parse(dbRunMock.mock.calls[0][1][2]);
expect(persistedData.spawned_from).toEqual({
table_id: 7256,
row_id: 999,
conversation_id: 3001,
});
expect(result.spawned_from.row_id).toBe(999);
});
});