godcrm/backend/routes/v3/__tests__/conversation-steps.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

613 lines
20 KiB
JavaScript

/**
* ADR-110: Conversation Steps API Tests
*
* Tests for:
* - GET /api/v3/chat/conversations/:id/steps (list steps)
* - GET /api/v3/chat/conversations/:id/steps/:stepId (step detail)
* - Deep merge of context_settings in PUT /ai/agents/:agentId
*
* Uses a minimal Express app with isolated route handlers to avoid
* interference from the full chat router's many middleware and routes.
*
* @see backend/routes/v3/chat.js
* @see ADR-110: Hierarchical Smart Context
*/
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
// ─── Mock DB ─────────────────────────────────────────────────
const mockDbGet = vi.fn();
const mockDbAll = vi.fn();
vi.mock('../../../database/connection.js', () => ({
dbGet: (...args) => mockDbGet(...args),
dbAll: (...args) => mockDbAll(...args),
dbRun: vi.fn(),
isPostgres: vi.fn(() => false),
safeJsonParse: (str, def) => {
if (str === null || str === undefined) return def;
if (typeof str === 'object') return str;
try { return JSON.parse(str); } catch { return def ?? null; }
},
}));
vi.mock('../../../utils/logger.js', () => ({
apiLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import express from 'express';
import request from 'supertest';
import { isPostgres, safeJsonParse } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
// ─── Re-create the route handlers in isolation ──────────────
// This avoids importing the full chat.js which has dozens of routes,
// module-level side effects, and complex mock requirements.
function success(res, data) {
return res.json({ success: true, data });
}
function badRequest(res, message) {
return res.status(400).json({ success: false, error: message });
}
function notFound(res, entity) {
return res.status(404).json({ success: false, error: `${entity} not found` });
}
function error(res, code, message, status = 500) {
return res.status(status).json({ success: false, error: message, code });
}
let app;
beforeAll(() => {
const router = express.Router();
// Minimal auth middleware
router.use((req, _res, next) => {
req.user = { id: 1, userId: 1 };
next();
});
// ── Steps list endpoint (copied from chat.js) ──
router.get('/conversations/:id/steps', async (req, res) => {
try {
const conversationId = parseInt(req.params.id);
if (!conversationId || isNaN(conversationId)) {
return badRequest(res, 'Invalid conversation ID');
}
const allowedTypes = ['thinking', 'tool_call', 'tool_result'];
const typeParam = req.query.type || 'tool_call,tool_result,thinking';
const requestedTypes = typeParam.split(',').map(t => t.trim()).filter(t => allowedTypes.includes(t));
if (requestedTypes.length === 0) {
return badRequest(res, `Invalid type filter. Allowed: ${allowedTypes.join(', ')}`);
}
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 100);
const page = Math.max(parseInt(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
const conversation = await mockDbGet(
`SELECT id FROM conversations WHERE id = ?`,
[conversationId]
);
if (!conversation) {
return notFound(res, 'Conversation');
}
const countRow = await mockDbGet(
`SELECT COUNT(*) as total FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL)`,
[conversationId, ...requestedTypes]
);
const total = countRow?.total || 0;
const steps = await mockDbAll(
`SELECT id, content_type, content, tool_results, created_at FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY created_at ASC LIMIT ? OFFSET ?`,
[conversationId, ...requestedTypes, limit, offset]
);
const PREVIEW_CHARS = 100;
const formattedSteps = steps.map(s => {
let toolName = null;
if (s.tool_results) {
const toolData = safeJsonParse(s.tool_results);
toolName = toolData?.tool || toolData?.name || null;
}
if (!toolName && s.content_type === 'tool_call' && s.content) {
const parsed = safeJsonParse(s.content);
toolName = parsed?.tool || parsed?.name || null;
if (!toolName && typeof s.content === 'string' && s.content.length < 100 && !s.content.includes(' ')) {
toolName = s.content;
}
}
return {
id: s.id,
type: s.content_type,
tool_name: toolName,
preview: s.content ? s.content.substring(0, PREVIEW_CHARS) : '',
timestamp: s.created_at,
};
});
const hasMore = offset + limit < total;
return success(res, {
steps: formattedSteps,
hasMore,
nextCursor: hasMore ? page + 1 : null,
total,
});
} catch (err) {
return error(res, 'STEPS_LIST_ERROR', err.message, 500);
}
});
// ── Step detail endpoint (copied from chat.js) ──
router.get('/conversations/:id/steps/:stepId', async (req, res) => {
try {
const conversationId = parseInt(req.params.id);
const stepId = parseInt(req.params.stepId);
if (!conversationId || isNaN(conversationId) || !stepId || isNaN(stepId)) {
return badRequest(res, 'Invalid conversation or step ID');
}
const step = await mockDbGet(
`SELECT id, conversation_id, content_type, role, content, tool_results, agent_id, model_used, tokens_in, tokens_out, created_at FROM messages WHERE id = ? AND conversation_id = ? AND content_type IN ('thinking', 'tool_call', 'tool_result') AND (is_deleted = 0 OR is_deleted IS NULL)`,
[stepId, conversationId]
);
if (!step) {
return notFound(res, 'Step');
}
const toolData = step.tool_results ? safeJsonParse(step.tool_results) : null;
return success(res, {
id: step.id,
type: step.content_type,
content: step.content,
tool_name: toolData?.tool || toolData?.name || null,
tool_args: toolData?.args || null,
metadata: {
role: step.role,
agent_id: step.agent_id,
model: step.model_used,
tokens: (step.tokens_in || step.tokens_out)
? { in: step.tokens_in, out: step.tokens_out }
: null,
result: toolData?.result || null,
},
conversation_id: step.conversation_id,
created_at: step.created_at,
});
} catch (err) {
return error(res, 'STEP_DETAIL_ERROR', err.message, 500);
}
});
app = express();
app.use(express.json());
app.use('/api/v3/chat', router);
});
describe('ADR-110: Conversation Steps API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ─── GET /conversations/:id/steps ─────────────────────────
describe('GET /conversations/:id/steps', () => {
it('should return steps for a valid conversation', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42 }) // conversation check
.mockResolvedValueOnce({ total: 2 }); // count
mockDbAll.mockResolvedValueOnce([
{
id: 101,
content_type: 'tool_call',
content: 'search_records',
tool_results: JSON.stringify({ tool: 'search_records', args: { query: 'test' } }),
created_at: '2026-03-01T10:00:00Z',
},
{
id: 102,
content_type: 'tool_result',
content: 'Found 5 records matching your query in the database',
tool_results: JSON.stringify({ tool: 'search_records', result: 'ok' }),
created_at: '2026-03-01T10:00:01Z',
},
]);
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps')
.expect(200);
expect(res.body.data.steps).toHaveLength(2);
expect(res.body.data.total).toBe(2);
expect(res.body.data.hasMore).toBe(false);
expect(res.body.data.nextCursor).toBeNull();
const step1 = res.body.data.steps[0];
expect(step1.id).toBe(101);
expect(step1.type).toBe('tool_call');
expect(step1.tool_name).toBe('search_records');
expect(step1.preview).toBeDefined();
expect(step1.timestamp).toBeDefined();
});
it('should return 404 for non-existent conversation', async () => {
mockDbGet.mockResolvedValueOnce(null);
const res = await request(app)
.get('/api/v3/chat/conversations/999/steps')
.expect(404);
expect(res.body.error).toBeDefined();
});
it('should filter by type parameter', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42 })
.mockResolvedValueOnce({ total: 1 });
mockDbAll.mockResolvedValueOnce([
{
id: 101,
content_type: 'thinking',
content: 'Let me think about this',
tool_results: null,
created_at: '2026-03-01T10:00:00Z',
},
]);
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps?type=thinking')
.expect(200);
expect(res.body.data.steps).toHaveLength(1);
expect(res.body.data.steps[0].type).toBe('thinking');
});
it('should reject invalid type parameter', async () => {
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps?type=invalid_type')
.expect(400);
expect(res.body.error).toBeDefined();
});
it('should handle pagination correctly', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42 })
.mockResolvedValueOnce({ total: 25 });
mockDbAll.mockResolvedValueOnce(
Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
content_type: 'tool_call',
content: `tool_${i}`,
tool_results: null,
created_at: '2026-03-01T10:00:00Z',
}))
);
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps?limit=10&page=1')
.expect(200);
expect(res.body.data.steps).toHaveLength(10);
expect(res.body.data.hasMore).toBe(true);
expect(res.body.data.nextCursor).toBe(2);
expect(res.body.data.total).toBe(25);
});
it('should return preview truncated to 100 chars', async () => {
const longContent = 'x'.repeat(200);
mockDbGet
.mockResolvedValueOnce({ id: 42 })
.mockResolvedValueOnce({ total: 1 });
mockDbAll.mockResolvedValueOnce([
{
id: 101,
content_type: 'tool_result',
content: longContent,
tool_results: null,
created_at: '2026-03-01T10:00:00Z',
},
]);
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps')
.expect(200);
expect(res.body.data.steps[0].preview).toHaveLength(100);
});
it('should handle invalid conversation ID', async () => {
const res = await request(app)
.get('/api/v3/chat/conversations/abc/steps')
.expect(400);
expect(res.body.error).toBeDefined();
});
it('should cap limit at 100', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42 })
.mockResolvedValueOnce({ total: 0 });
mockDbAll.mockResolvedValueOnce([]);
await request(app)
.get('/api/v3/chat/conversations/42/steps?limit=500')
.expect(200);
// Verify LIMIT param is capped
const params = mockDbAll.mock.calls[0][1];
const limitParam = params[params.length - 2];
expect(limitParam).toBeLessThanOrEqual(100);
});
it('should default to all step types when no type param', async () => {
mockDbGet
.mockResolvedValueOnce({ id: 42 })
.mockResolvedValueOnce({ total: 0 });
mockDbAll.mockResolvedValueOnce([]);
await request(app)
.get('/api/v3/chat/conversations/42/steps')
.expect(200);
// Verify all three types are in the params
const params = mockDbAll.mock.calls[0][1];
expect(params).toContain('tool_call');
expect(params).toContain('tool_result');
expect(params).toContain('thinking');
});
});
// ─── GET /conversations/:id/steps/:stepId ──────────────────
describe('GET /conversations/:id/steps/:stepId', () => {
it('should return full step detail', async () => {
mockDbGet.mockResolvedValueOnce({
id: 101,
conversation_id: 42,
content_type: 'tool_result',
role: 'assistant',
content: 'Full detailed result content here',
tool_results: JSON.stringify({
tool: 'search_records',
args: { query: 'open tasks', table_id: 5 },
result: { records: [{ id: 1, name: 'Task 1' }] },
}),
agent_id: 7,
model_used: 'gpt-4-turbo',
tokens_in: 500,
tokens_out: 200,
created_at: '2026-03-01T10:00:00Z',
});
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps/101')
.expect(200);
const data = res.body.data;
expect(data.id).toBe(101);
expect(data.type).toBe('tool_result');
expect(data.content).toBe('Full detailed result content here');
expect(data.tool_name).toBe('search_records');
expect(data.tool_args).toEqual({ query: 'open tasks', table_id: 5 });
expect(data.metadata.model).toBe('gpt-4-turbo');
expect(data.metadata.tokens).toEqual({ in: 500, out: 200 });
expect(data.metadata.result).toBeDefined();
expect(data.conversation_id).toBe(42);
expect(data.created_at).toBe('2026-03-01T10:00:00Z');
});
it('should return 404 for non-existent step', async () => {
mockDbGet.mockResolvedValueOnce(null);
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps/999')
.expect(404);
expect(res.body.error).toBeDefined();
});
it('should return step without tokens when not tracked', async () => {
mockDbGet.mockResolvedValueOnce({
id: 101,
conversation_id: 42,
content_type: 'thinking',
role: 'assistant',
content: 'Some thinking process',
tool_results: null,
agent_id: null,
model_used: null,
tokens_in: null,
tokens_out: null,
created_at: '2026-03-01T10:00:00Z',
});
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps/101')
.expect(200);
const data = res.body.data;
expect(data.type).toBe('thinking');
expect(data.tool_name).toBeNull();
expect(data.tool_args).toBeNull();
expect(data.metadata.tokens).toBeNull();
});
it('should handle invalid step ID', async () => {
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps/abc')
.expect(400);
expect(res.body.error).toBeDefined();
});
it('should scope step to conversation_id for security', async () => {
mockDbGet.mockResolvedValueOnce(null); // step not found for this conversation
const res = await request(app)
.get('/api/v3/chat/conversations/42/steps/101')
.expect(404);
// Verify query includes both stepId AND conversationId
const queryArgs = mockDbGet.mock.calls[0][1];
expect(queryArgs).toContain(101); // stepId
expect(queryArgs).toContain(42); // conversationId
});
});
});
// ─── Deep Merge context_settings Tests ────────────────────
describe('ADR-110: Deep merge context_settings (ai-agents PUT)', () => {
/**
* Since the deepMergeContextSettings function is private to ai-agents.js,
* we test the logic by replicating it here (same algorithm).
* This validates the merge behavior documented in ADR-110 AC12.
*/
function deepMergeContextSettings(existing, incoming) {
const merged = { ...existing };
for (const key of Object.keys(incoming)) {
if (
key === 'context_levels' &&
typeof incoming[key] === 'object' && incoming[key] !== null &&
typeof merged[key] === 'object' && merged[key] !== null
) {
merged[key] = { ...merged[key], ...incoming[key] };
} else {
merged[key] = incoming[key];
}
}
return merged;
}
it('should deep merge context_levels without wiping sibling fields', () => {
const existing = {
max_history: 100,
context_levels: {
thinking: false,
thinking_preview_chars: 200,
tool_summaries: false,
tool_preview_chars: 100,
full_tool_results: false,
},
};
const incoming = {
context_levels: {
thinking: true,
},
};
const result = deepMergeContextSettings(existing, incoming);
expect(result.max_history).toBe(100);
expect(result.context_levels.thinking).toBe(true);
expect(result.context_levels.thinking_preview_chars).toBe(200);
expect(result.context_levels.tool_summaries).toBe(false);
expect(result.context_levels.tool_preview_chars).toBe(100);
expect(result.context_levels.full_tool_results).toBe(false);
});
it('should overwrite top-level keys normally', () => {
const existing = { max_history: 100, some_flag: true };
const incoming = { max_history: 50 };
const result = deepMergeContextSettings(existing, incoming);
expect(result.max_history).toBe(50);
expect(result.some_flag).toBe(true);
});
it('should create context_levels when it does not exist yet', () => {
const existing = { max_history: 100 };
const incoming = {
context_levels: { thinking: true, tool_summaries: true },
};
const result = deepMergeContextSettings(existing, incoming);
expect(result.context_levels).toEqual({ thinking: true, tool_summaries: true });
expect(result.max_history).toBe(100);
});
it('should handle empty incoming gracefully', () => {
const existing = {
max_history: 100,
context_levels: { thinking: true },
};
const result = deepMergeContextSettings(existing, {});
expect(result).toEqual(existing);
});
it('should handle empty existing gracefully', () => {
const incoming = {
context_levels: { thinking: true },
};
const result = deepMergeContextSettings({}, incoming);
expect(result.context_levels).toEqual({ thinking: true });
});
it('should handle null context_levels in incoming (overwrite)', () => {
const existing = {
context_levels: { thinking: true },
};
const incoming = { context_levels: null };
const result = deepMergeContextSettings(existing, incoming);
expect(result.context_levels).toBeNull();
});
it('should preserve multiple nested fields during partial update', () => {
const existing = {
max_history: 75,
context_levels: {
thinking: true,
thinking_preview_chars: 300,
tool_summaries: true,
tool_preview_chars: 150,
full_tool_results: false,
},
};
const incoming = {
context_levels: {
full_tool_results: true,
tool_preview_chars: 200,
},
};
const result = deepMergeContextSettings(existing, incoming);
expect(result.max_history).toBe(75);
expect(result.context_levels.thinking).toBe(true);
expect(result.context_levels.thinking_preview_chars).toBe(300);
expect(result.context_levels.tool_summaries).toBe(true);
expect(result.context_levels.tool_preview_chars).toBe(200);
expect(result.context_levels.full_tool_results).toBe(true);
});
});