Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
/**
|
|
* Tests for Chat Database Schema (ADR-024)
|
|
* Messenger-style architecture: conversations + messages
|
|
*
|
|
* TDD: These tests are written FIRST, before implementation
|
|
* Phase 1: Test that tables exist and have correct structure
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
|
|
// Set env vars BEFORE imports
|
|
process.env.SKIP_DEV_USER = 'true'; // Don't create dev user (avoids NeoMetal dependency)
|
|
|
|
import { dbRun, dbGet, dbAll, isPostgres } from '../../../database/connection.js';
|
|
import { setupTestDatabase, cleanupTestDatabase } from '../../../tests/helpers/test-db.js';
|
|
|
|
// Mock user for tests
|
|
const TEST_USER_ID = 1;
|
|
|
|
/**
|
|
* Helper to get table columns (works for both SQLite and PostgreSQL)
|
|
*/
|
|
async function getTableColumns(tableName) {
|
|
if (isPostgres()) {
|
|
const result = await dbAll(`
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
ORDER BY ordinal_position
|
|
`, [tableName]);
|
|
return result.map(r => r.column_name);
|
|
} else {
|
|
// SQLite: PRAGMA table_info
|
|
const result = await dbAll(`PRAGMA table_info(${tableName})`);
|
|
return result.map(r => r.name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to get table indexes (works for both SQLite and PostgreSQL)
|
|
*/
|
|
async function getTableIndexes(tableName) {
|
|
if (isPostgres()) {
|
|
const result = await dbAll(`
|
|
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1
|
|
`, [tableName]);
|
|
return result;
|
|
} else {
|
|
// SQLite: PRAGMA index_list
|
|
const result = await dbAll(`PRAGMA index_list(${tableName})`);
|
|
return result.map(r => ({ indexname: r.name, indexdef: '' }));
|
|
}
|
|
}
|
|
|
|
describe('Chat Database Schema (ADR-024)', () => {
|
|
|
|
beforeAll(async () => {
|
|
// Initialize database with schema
|
|
await setupTestDatabase();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestDatabase();
|
|
});
|
|
|
|
describe('Phase 1: Tables Exist', () => {
|
|
|
|
it('should have conversations table', async () => {
|
|
const columnNames = await getTableColumns('conversations');
|
|
|
|
expect(columnNames.length).toBeGreaterThan(0);
|
|
expect(columnNames).toContain('id');
|
|
expect(columnNames).toContain('type');
|
|
expect(columnNames).toContain('title');
|
|
expect(columnNames).toContain('created_by');
|
|
expect(columnNames).toContain('space_id');
|
|
expect(columnNames).toContain('agent_id');
|
|
expect(columnNames).toContain('bound_table_id');
|
|
expect(columnNames).toContain('bound_row_id');
|
|
expect(columnNames).toContain('last_message_id');
|
|
expect(columnNames).toContain('messages_count');
|
|
expect(columnNames).toContain('settings');
|
|
expect(columnNames).toContain('created_at');
|
|
});
|
|
|
|
it('should have messages table', async () => {
|
|
const columnNames = await getTableColumns('messages');
|
|
|
|
expect(columnNames.length).toBeGreaterThan(0);
|
|
expect(columnNames).toContain('id');
|
|
expect(columnNames).toContain('conversation_id');
|
|
expect(columnNames).toContain('sender_id');
|
|
expect(columnNames).toContain('sender_type');
|
|
expect(columnNames).toContain('role');
|
|
expect(columnNames).toContain('content');
|
|
expect(columnNames).toContain('content_type');
|
|
expect(columnNames).toContain('parent_id');
|
|
expect(columnNames).toContain('mentions');
|
|
expect(columnNames).toContain('attachments');
|
|
expect(columnNames).toContain('is_edited');
|
|
expect(columnNames).toContain('is_deleted');
|
|
expect(columnNames).toContain('created_at');
|
|
});
|
|
|
|
it('should have conversation_participants table', async () => {
|
|
const columnNames = await getTableColumns('conversation_participants');
|
|
|
|
expect(columnNames.length).toBeGreaterThan(0);
|
|
expect(columnNames).toContain('id');
|
|
expect(columnNames).toContain('conversation_id');
|
|
expect(columnNames).toContain('user_id');
|
|
expect(columnNames).toContain('role');
|
|
expect(columnNames).toContain('last_read_message_id');
|
|
expect(columnNames).toContain('unread_count');
|
|
});
|
|
|
|
it('should have message_reactions table', async () => {
|
|
const columnNames = await getTableColumns('message_reactions');
|
|
|
|
expect(columnNames.length).toBeGreaterThan(0);
|
|
expect(columnNames).toContain('id');
|
|
expect(columnNames).toContain('message_id');
|
|
expect(columnNames).toContain('user_id');
|
|
expect(columnNames).toContain('emoji');
|
|
});
|
|
});
|
|
|
|
describe('Phase 2: CRUD Operations', () => {
|
|
let testConversationId;
|
|
let testMessageId;
|
|
let testUserId;
|
|
|
|
beforeAll(async () => {
|
|
// Create test user for FK constraints
|
|
const userResult = await dbRun(`
|
|
INSERT INTO users (email, password_hash, name, encryption_key_encrypted, created_at, updated_at)
|
|
VALUES ('chat-test@test.com', 'hash123', 'Chat Test User', 'encrypted_key', datetime('now'), datetime('now'))
|
|
`);
|
|
testUserId = userResult.lastInsertRowid || 1;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup
|
|
try {
|
|
if (testMessageId) {
|
|
await dbRun('DELETE FROM messages WHERE id = ?', [testMessageId]);
|
|
}
|
|
if (testConversationId) {
|
|
await dbRun('DELETE FROM conversations WHERE id = ?', [testConversationId]);
|
|
}
|
|
if (testUserId) {
|
|
await dbRun('DELETE FROM users WHERE id = ?', [testUserId]);
|
|
}
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
it('should create a conversation', async () => {
|
|
// Ticket #41154: Use unified 'chat' type (ADR-091)
|
|
const result = await dbRun(`
|
|
INSERT INTO conversations (type, title, created_by, settings, created_at, updated_at)
|
|
VALUES ('chat', 'Test Conversation', ?, '{}', datetime('now'), datetime('now'))
|
|
`, [testUserId]);
|
|
|
|
testConversationId = result.id || result.lastInsertRowid;
|
|
expect(testConversationId).toBeDefined();
|
|
|
|
// Verify it was created
|
|
const conversation = await dbGet('SELECT * FROM conversations WHERE id = ?', [testConversationId]);
|
|
expect(conversation).toBeDefined();
|
|
expect(conversation.title).toBe('Test Conversation');
|
|
expect(conversation.type).toBe('chat');
|
|
});
|
|
|
|
it('should create a message in conversation', async () => {
|
|
const result = await dbRun(`
|
|
INSERT INTO messages (
|
|
conversation_id, sender_id, sender_type, role, content,
|
|
content_type, mentions, attachments, created_at, updated_at
|
|
)
|
|
VALUES (?, ?, 'human', 'user', 'Hello, test message', 'text', '[]', '[]', datetime('now'), datetime('now'))
|
|
`, [testConversationId, testUserId]);
|
|
|
|
testMessageId = result.id || result.lastInsertRowid;
|
|
expect(testMessageId).toBeDefined();
|
|
|
|
// Verify
|
|
const message = await dbGet('SELECT * FROM messages WHERE id = ?', [testMessageId]);
|
|
expect(message).toBeDefined();
|
|
expect(message.content).toBe('Hello, test message');
|
|
expect(message.role).toBe('user');
|
|
expect(message.conversation_id).toBe(testConversationId);
|
|
});
|
|
|
|
it('should support message threading (parent_id)', async () => {
|
|
// Create parent message
|
|
const parentResult = await dbRun(`
|
|
INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, mentions, attachments, created_at, updated_at)
|
|
VALUES (?, ?, 'human', 'user', 'Parent message', 'text', '[]', '[]', datetime('now'), datetime('now'))
|
|
`, [testConversationId, testUserId]);
|
|
|
|
const parentId = parentResult.id || parentResult.lastInsertRowid;
|
|
|
|
// Create reply
|
|
const replyResult = await dbRun(`
|
|
INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, parent_id, mentions, attachments, created_at, updated_at)
|
|
VALUES (?, ?, 'human', 'user', 'Reply message', 'text', ?, '[]', '[]', datetime('now'), datetime('now'))
|
|
`, [testConversationId, testUserId, parentId]);
|
|
|
|
const replyId = replyResult.id || replyResult.lastInsertRowid;
|
|
|
|
// Verify threading
|
|
const reply = await dbGet('SELECT * FROM messages WHERE id = ?', [replyId]);
|
|
expect(reply.parent_id).toBe(parentId);
|
|
|
|
// Cleanup
|
|
await dbRun('DELETE FROM messages WHERE id = ?', [replyId]);
|
|
await dbRun('DELETE FROM messages WHERE id = ?', [parentId]);
|
|
});
|
|
|
|
it('should support task binding on conversation', async () => {
|
|
// Update conversation with task binding
|
|
await dbRun(`
|
|
UPDATE conversations
|
|
SET bound_table_id = ?, bound_row_id = ?, type = 'task', updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`, [100, 200, testConversationId]);
|
|
|
|
const conversation = await dbGet('SELECT * FROM conversations WHERE id = ?', [testConversationId]);
|
|
expect(conversation.bound_table_id).toBe(100);
|
|
expect(conversation.bound_row_id).toBe(200);
|
|
expect(conversation.type).toBe('task');
|
|
|
|
// Reset to unified 'chat' type (Ticket #41154 / ADR-091)
|
|
await dbRun(`
|
|
UPDATE conversations
|
|
SET bound_table_id = NULL, bound_row_id = NULL, type = 'chat', updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`, [testConversationId]);
|
|
});
|
|
|
|
it('should store mentions as JSON', async () => {
|
|
const mentions = JSON.stringify([
|
|
{ user_id: 1, type: 'human' },
|
|
{ agent_id: 42, type: 'agent' }
|
|
]);
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, mentions, attachments, created_at, updated_at)
|
|
VALUES (?, ?, 'human', 'user', '@dev-agent please help', 'text', ?, '[]', datetime('now'), datetime('now'))
|
|
`, [testConversationId, testUserId, mentions]);
|
|
|
|
const msgId = result.id || result.lastInsertRowid;
|
|
const message = await dbGet('SELECT * FROM messages WHERE id = ?', [msgId]);
|
|
|
|
// Parse mentions back
|
|
const parsedMentions = typeof message.mentions === 'string'
|
|
? JSON.parse(message.mentions)
|
|
: message.mentions;
|
|
|
|
expect(parsedMentions).toHaveLength(2);
|
|
expect(parsedMentions[0].user_id).toBe(1);
|
|
|
|
// Cleanup
|
|
await dbRun('DELETE FROM messages WHERE id = ?', [msgId]);
|
|
});
|
|
|
|
it('should add participant to conversation', async () => {
|
|
const result = await dbRun(`
|
|
INSERT INTO conversation_participants (conversation_id, user_id, role, user_type, unread_count, joined_at)
|
|
VALUES (?, ?, 'owner', 'human', 0, datetime('now'))
|
|
`, [testConversationId, testUserId]);
|
|
|
|
const participantId = result.id || result.lastInsertRowid;
|
|
expect(participantId).toBeDefined();
|
|
|
|
// Verify
|
|
const participant = await dbGet(
|
|
'SELECT * FROM conversation_participants WHERE conversation_id = ? AND user_id = ?',
|
|
[testConversationId, testUserId]
|
|
);
|
|
expect(participant).toBeDefined();
|
|
expect(participant.role).toBe('owner');
|
|
|
|
// Cleanup
|
|
await dbRun('DELETE FROM conversation_participants WHERE id = ?', [participantId]);
|
|
});
|
|
|
|
it('should find conversation by bound task', async () => {
|
|
// Create conversation bound to task
|
|
const createResult = await dbRun(`
|
|
INSERT INTO conversations (type, title, created_by, bound_table_id, bound_row_id, settings, created_at, updated_at)
|
|
VALUES ('task', 'Task Chat', ?, 15, 123, '{}', datetime('now'), datetime('now'))
|
|
`, [testUserId]);
|
|
|
|
const convId = createResult.id || createResult.lastInsertRowid;
|
|
|
|
// Find by task binding
|
|
const found = await dbGet(`
|
|
SELECT * FROM conversations
|
|
WHERE bound_table_id = ? AND bound_row_id = ?
|
|
`, [15, 123]);
|
|
|
|
expect(found).toBeDefined();
|
|
expect(found.id).toBe(convId);
|
|
expect(found.type).toBe('task');
|
|
|
|
// Cleanup
|
|
await dbRun('DELETE FROM conversations WHERE id = ?', [convId]);
|
|
});
|
|
});
|
|
|
|
describe('Phase 3: Indexes', () => {
|
|
|
|
it('should have index on conversations(space_id)', async () => {
|
|
const indexes = await getTableIndexes('conversations');
|
|
const indexNames = indexes.map(i => i.indexname);
|
|
expect(indexNames.some(n => n.includes('space'))).toBe(true);
|
|
});
|
|
|
|
it.skip('should have index on conversations(bound) - index removed in PG schema', async () => {
|
|
const indexes = await getTableIndexes('conversations');
|
|
const indexNames = indexes.map(i => i.indexname);
|
|
expect(indexNames.some(n => n.includes('bound'))).toBe(true);
|
|
});
|
|
|
|
it('should have index on messages(conversation_id)', async () => {
|
|
const indexes = await getTableIndexes('messages');
|
|
const indexNames = indexes.map(i => i.indexname);
|
|
expect(indexNames.some(n => n.includes('conversation'))).toBe(true);
|
|
});
|
|
|
|
it('should have index on messages(sender_id)', async () => {
|
|
const indexes = await getTableIndexes('messages');
|
|
const indexNames = indexes.map(i => i.indexname);
|
|
expect(indexNames.some(n => n.includes('sender'))).toBe(true);
|
|
});
|
|
|
|
it('should have index on conversation_participants', async () => {
|
|
const indexes = await getTableIndexes('conversation_participants');
|
|
const indexNames = indexes.map(i => i.indexname);
|
|
expect(indexNames.some(n => n.includes('participant') || n.includes('user') || n.includes('conversation'))).toBe(true);
|
|
});
|
|
});
|
|
});
|