/** * ADR-024: AI Conversations API v2 Tests * * Tests for migrating AI chat from table_rows JSON to normalized tables: * - conversations (type='ai_chat') * - messages * - conversation_participants */ // Set env vars BEFORE imports - must match what auth middleware uses const JWT_SECRET = 'test-secret-key'; process.env.SKIP_DEV_USER = 'true'; process.env.JWT_SECRET = JWT_SECRET; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import express from 'express'; import { dbAll, dbGet, dbRun, isPostgres } from '../../../database/connection.js'; import { setupTestDatabase, cleanupTestDatabase } from '../../../tests/helpers/test-db.js'; describe('ADR-024: AI Conversations API v2', () => { let app; let testUserId; let authToken; let testAgentId; beforeAll(async () => { await setupTestDatabase(); // Create test user const ts = Date.now(); const userResult = await dbRun( `INSERT INTO users (email, name, password_hash, role, encryption_key_encrypted, created_at) VALUES (?, 'AI Conv Test', 'hash', 'user', 'enc123', NOW())`, [`ai-conv-test-${ts}@test.com`] ); testUserId = userResult.lastInsertRowid; // Create test AI agent in users table (agent user) const agentResult = await dbRun( `INSERT INTO users (email, name, password_hash, role, user_type, encryption_key_encrypted, created_at) VALUES (?, 'Test Agent', 'hash', 'user', 'agent', 'enc123', NOW())`, [`agent-${ts}@test.com`] ); testAgentId = agentResult.lastInsertRowid; // Create auth token - include 'id' as required by requireAuth authToken = jwt.sign( { id: testUserId, userId: testUserId, email: `ai-conv-test-${ts}@test.com`, role: 'user' }, JWT_SECRET ); // Create Express app - ai-agents.js has its own authenticate middleware app = express(); app.use(express.json()); // Ensure 'tables' table exists (SQLite subqueries in list-conversations reference it) await dbRun(` CREATE TABLE IF NOT EXISTS tables ( id INTEGER PRIMARY KEY, name TEXT, display_name TEXT, icon TEXT ) `); // Import and mount ai-agents routes (contains /ai/conversations endpoints) const aiAgentsRoutes = await import('../ai-agents.js'); app.use('/api/v3/ai', aiAgentsRoutes.default); }); afterAll(async () => { await cleanupTestDatabase(); }); // ========================================== // POST /ai/conversations - Create AI conversation // ========================================== describe('POST /api/v3/ai/conversations', () => { it('should create a new AI conversation in conversations table', async () => { const response = await request(app) .post('/api/v3/ai/conversations') .set('Authorization', `Bearer ${authToken}`) .send({ title: 'Test AI Chat', agentId: testAgentId, agentName: 'Test Agent' }); expect(response.status).toBe(201); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); expect(response.body.data.id).toBeDefined(); expect(response.body.data.title).toBe('Test AI Chat'); // ADR-091: conversations now use unified 'chat' type instead of 'ai_chat' expect(response.body.data.type).toBe('chat'); // Verify in database - should be in conversations table, NOT table_rows const conv = await dbGet( 'SELECT * FROM conversations WHERE id = ?', [response.body.data.id] ); expect(conv).toBeDefined(); expect(conv.type).toBe('chat'); expect(conv.created_by).toBe(testUserId); }); it('should add creator as participant with admin role', async () => { const response = await request(app) .post('/api/v3/ai/conversations') .set('Authorization', `Bearer ${authToken}`) .send({ title: 'Participant Test', agentId: testAgentId }); expect(response.status).toBe(201); const participant = await dbGet( 'SELECT * FROM conversation_participants WHERE conversation_id = ? AND user_id = ?', [response.body.data.id, testUserId] ); expect(participant).toBeDefined(); expect(participant.role).toBe('admin'); }); it('should require authentication', async () => { const response = await request(app) .post('/api/v3/ai/conversations') .send({ title: 'No Auth' }); expect(response.status).toBe(401); }); }); // ========================================== // GET /ai/conversations - List AI conversations // ========================================== describe('GET /api/v3/ai/conversations', () => { let conversationId; beforeEach(async () => { // Create test conversation directly in DB const result = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('List Test Conv', 'ai_chat', ?, NOW(), NOW())`, [testUserId] ); conversationId = result.lastInsertRowid; // Add participant await dbRun( `INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', NOW())`, [conversationId, testUserId] ); }); it('should return list of AI conversations for current user', async () => { const response = await request(app) .get('/api/v3/ai/conversations') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // API returns { conversations: [...] } not a direct array expect(Array.isArray(response.body.data.conversations)).toBe(true); const conv = response.body.data.conversations.find(c => c.id === conversationId); expect(conv).toBeDefined(); expect(conv.title).toBe('List Test Conv'); }); it('should only return conversations where user is participant', async () => { // Create another user's conversation const otherUserResult = await dbRun( `INSERT INTO users (email, name, password_hash, role, encryption_key_encrypted, created_at) VALUES ('other@test.com', 'Other', 'hash', 'user', 'enc123', NOW())` ); const otherUserId = otherUserResult.lastInsertRowid; const otherConvResult = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('Other User Conv', 'ai_chat', ?, NOW(), NOW())`, [otherUserId] ); await dbRun( `INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', NOW())`, [otherConvResult.lastInsertRowid, otherUserId] ); const response = await request(app) .get('/api/v3/ai/conversations') .set('Authorization', `Bearer ${authToken}`); // Should NOT include other user's conversation const otherConv = response.body.data.conversations.find(c => c.id === otherConvResult.lastInsertRowid); expect(otherConv).toBeUndefined(); // Cleanup await dbRun('DELETE FROM conversation_participants WHERE conversation_id = ?', [otherConvResult.lastInsertRowid]); await dbRun('DELETE FROM conversations WHERE id = ?', [otherConvResult.lastInsertRowid]); await dbRun('DELETE FROM users WHERE id = ?', [otherUserId]); }); it('should include messages count and last message', async () => { // Add messages to conversation await dbRun( `INSERT INTO messages (conversation_id, sender_id, role, content, content_type, created_at) VALUES (?, ?, 'user', 'Hello AI', 'text', NOW())`, [conversationId, testUserId] ); await dbRun( `INSERT INTO messages (conversation_id, sender_id, role, content, content_type, created_at) VALUES (?, ?, 'assistant', 'Hi there!', 'text', NOW())`, [conversationId, testAgentId] ); const response = await request(app) .get('/api/v3/ai/conversations') .set('Authorization', `Bearer ${authToken}`); const conv = response.body.data.conversations.find(c => c.id === conversationId); expect(Number(conv.messagesCount)).toBe(2); expect(conv.lastMessage).toContain('Hi there'); }); }); // ========================================== // GET /ai/conversations/:id - Get single conversation // ========================================== describe('GET /api/v3/ai/conversations/:id', () => { let conversationId; beforeEach(async () => { // Create conversation with messages const result = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('Detail Test', 'ai_chat', ?, NOW(), NOW())`, [testUserId] ); conversationId = result.lastInsertRowid; await dbRun( `INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', NOW())`, [conversationId, testUserId] ); // Add messages await dbRun( `INSERT INTO messages (conversation_id, sender_id, role, content, content_type, created_at) VALUES (?, ?, 'user', 'First message', 'text', NOW() - INTERVAL '2 minutes')`, [conversationId, testUserId] ); await dbRun( `INSERT INTO messages (conversation_id, sender_id, role, content, content_type, created_at) VALUES (?, ?, 'assistant', 'Response', 'text', NOW() - INTERVAL '1 minute')`, [conversationId, testAgentId] ); }); it('should return conversation with all messages', async () => { const response = await request(app) .get(`/api/v3/ai/conversations/${conversationId}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.id).toBe(conversationId); expect(response.body.data.title).toBe('Detail Test'); expect(Array.isArray(response.body.data.messages)).toBe(true); expect(response.body.data.messages.length).toBe(2); // Messages should be in chronological order // Note: messages are returned DESC then reversed, so oldest first expect(response.body.data.messages[0].content).toBe('First message'); expect(response.body.data.messages[1].content).toBe('Response'); }); it('should return 404 for non-existent conversation', async () => { const response = await request(app) .get('/api/v3/ai/conversations/999999') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(404); }); it('should return 403 if user is not participant', async () => { // Create other user's conversation (without adding testUser as participant) const otherResult = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('Private', 'ai_chat', ?, NOW(), NOW())`, [testAgentId] // Use testAgentId (exists) but don't add testUserId as participant ); const response = await request(app) .get(`/api/v3/ai/conversations/${otherResult.lastInsertRowid}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(403); // Cleanup await dbRun('DELETE FROM conversations WHERE id = ?', [otherResult.lastInsertRowid]); }); }); // ========================================== // PUT /ai/conversations/:id - Add message // ========================================== describe('PUT /api/v3/ai/conversations/:id', () => { let conversationId; beforeEach(async () => { const result = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('Message Test', 'ai_chat', ?, NOW(), NOW())`, [testUserId] ); conversationId = result.lastInsertRowid; await dbRun( `INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', NOW())`, [conversationId, testUserId] ); }); it('should add a new message to the conversation', async () => { const response = await request(app) .put(`/api/v3/ai/conversations/${conversationId}`) .set('Authorization', `Bearer ${authToken}`) .send({ message: { role: 'user', content: 'New message content' } }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // Verify message was added to messages table const messages = await dbAll( 'SELECT * FROM messages WHERE conversation_id = ?', [conversationId] ); expect(messages.length).toBe(1); expect(messages[0].content).toBe('New message content'); expect(messages[0].role).toBe('user'); }); it('should update conversation.updated_at on new message', async () => { const before = await dbGet('SELECT updated_at FROM conversations WHERE id = ?', [conversationId]); // Wait 1.1 seconds to ensure we cross a second boundary (SQLite datetime precision is seconds) await new Promise(r => setTimeout(r, 1100)); await request(app) .put(`/api/v3/ai/conversations/${conversationId}`) .set('Authorization', `Bearer ${authToken}`) .send({ message: { role: 'user', content: 'Test' } }); const after = await dbGet('SELECT updated_at FROM conversations WHERE id = ?', [conversationId]); expect(new Date(after.updated_at).getTime()).toBeGreaterThan(new Date(before.updated_at).getTime()); }); it('should allow adding assistant messages', async () => { const response = await request(app) .put(`/api/v3/ai/conversations/${conversationId}`) .set('Authorization', `Bearer ${authToken}`) .send({ message: { role: 'assistant', content: 'AI response with tool results', toolResults: [{ tool: 'search', result: 'data' }] } }); expect(response.status).toBe(200); const msg = await dbGet( 'SELECT * FROM messages WHERE conversation_id = ? AND role = ?', [conversationId, 'assistant'] ); expect(msg).toBeDefined(); expect(msg.content).toBe('AI response with tool results'); }); }); // ========================================== // DELETE /ai/conversations/:id // ========================================== describe('DELETE /api/v3/ai/conversations/:id', () => { it('should delete conversation and all its messages', async () => { // Create conversation with messages const result = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('To Delete', 'ai_chat', ?, NOW(), NOW())`, [testUserId] ); const conversationId = result.lastInsertRowid; await dbRun( `INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', NOW())`, [conversationId, testUserId] ); await dbRun( `INSERT INTO messages (conversation_id, sender_id, role, content, content_type, created_at) VALUES (?, ?, 'user', 'To be deleted', 'text', NOW())`, [conversationId, testUserId] ); const response = await request(app) .delete(`/api/v3/ai/conversations/${conversationId}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // Verify cascade delete const conv = await dbGet('SELECT * FROM conversations WHERE id = ?', [conversationId]); expect(conv).toBeFalsy(); const messages = await dbAll('SELECT * FROM messages WHERE conversation_id = ?', [conversationId]); expect(messages.length).toBe(0); const participants = await dbAll('SELECT * FROM conversation_participants WHERE conversation_id = ?', [conversationId]); expect(participants.length).toBe(0); }); it('should return 403 if user is not participant', async () => { const result = await dbRun( `INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES ('Not Mine', 'ai_chat', ?, NOW(), NOW())`, [testAgentId] // Use testAgentId (exists) but don't add testUserId as participant ); const response = await request(app) .delete(`/api/v3/ai/conversations/${result.lastInsertRowid}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(403); // Cleanup await dbRun('DELETE FROM conversations WHERE id = ?', [result.lastInsertRowid]); }); }); });