chore: more unit tests, this time management of chat sessions

This commit is contained in:
2026-02-14 17:43:04 +01:00
parent 06bf0cf895
commit daac97824d

View File

@@ -0,0 +1,793 @@
/**
* ChatEngine Unit Tests
*
* Tests the REAL ChatEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ChatEngine, ChatConversationData, ChatMessageData } from '../../src/main/engine/ChatEngine';
// Create mock data stores
const mockConversations = new Map<string, any>();
const mockMessages = new Map<number, any>();
const mockSettings = new Map<string, any>();
let messageIdCounter = 1;
// Create chainable mock for Drizzle ORM
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(function (this: any) {
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve([])),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => ({
returning: vi.fn((returnSpec: any) => {
// Handle message inserts with auto-increment ID
if (data && data.conversationId && data.role) {
const id = messageIdCounter++;
mockMessages.set(id, { ...data, id });
return Promise.resolve([{ id }]);
}
// Handle conversation inserts
if (data && data.id && data.title) {
mockConversations.set(data.id, data);
}
// Handle settings inserts
if (data && data.key) {
mockSettings.set(data.key, data);
}
return Promise.resolve([{ id: data?.id || 1 }]);
}),
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
// Create mock DatabaseConnection
const mockDbConnection = {
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => null),
};
// Mock uuid
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).substr(2, 9)),
}));
describe('ChatEngine', () => {
let chatEngine: ChatEngine;
beforeEach(() => {
vi.clearAllMocks();
mockConversations.clear();
mockMessages.clear();
mockSettings.clear();
messageIdCounter = 1;
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => ({
returning: vi.fn((returnSpec: any) => {
if (data && data.conversationId && data.role) {
const id = messageIdCounter++;
mockMessages.set(id, { ...data, id });
return Promise.resolve([{ id }]);
}
if (data && data.id && data.title) {
mockConversations.set(data.id, data);
}
if (data && data.key) {
mockSettings.set(data.key, data);
}
return Promise.resolve([{ id: data?.id || 1 }]);
}),
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
})),
}));
chatEngine = new ChatEngine(mockDbConnection as any);
});
describe('Constructor', () => {
it('should create a ChatEngine instance', () => {
expect(chatEngine).toBeInstanceOf(ChatEngine);
});
it('should store database reference', () => {
expect(mockDbConnection.getLocal).toBeDefined();
});
});
describe('createConversation', () => {
it('should create a conversation with default values', async () => {
const result = await chatEngine.createConversation();
expect(result.id).toMatch(/^chat_mock-uuid-/);
expect(result.title).toBe('New Chat');
expect(result.model).toBe('claude-sonnet-4');
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
it('should create conversation with custom title', async () => {
const result = await chatEngine.createConversation({ title: 'My Custom Chat' });
expect(result.title).toBe('My Custom Chat');
});
it('should create conversation with custom model', async () => {
const result = await chatEngine.createConversation({ model: 'gpt-4-turbo' });
expect(result.model).toBe('gpt-4-turbo');
});
it('should add system prompt as first message when provided', async () => {
const systemPrompt = 'You are a helpful assistant.';
await chatEngine.createConversation({ systemPrompt });
// Should have called insert twice - once for conversation, once for message
expect(mockLocalDb.insert).toHaveBeenCalledTimes(2);
});
it('should insert conversation into database', async () => {
await chatEngine.createConversation({ title: 'DB Test' });
expect(mockLocalDb.insert).toHaveBeenCalled();
});
it('should set createdAt and updatedAt to same timestamp', async () => {
const result = await chatEngine.createConversation();
expect(result.createdAt.getTime()).toBe(result.updatedAt.getTime());
});
});
describe('getConversation', () => {
it('should return null for non-existent conversation', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([]), // Return empty array
});
return chain;
});
const result = await chatEngine.getConversation('non-existent-id');
expect(result).toBeNull();
});
it('should return conversation with messages', async () => {
const conversationData = {
id: 'chat_test-id',
title: 'Test Conversation',
model: 'claude-sonnet-4',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15'),
};
const messageData = [
{ id: 1, conversationId: 'chat_test-id', role: 'user', content: 'Hello', createdAt: new Date() },
{ id: 2, conversationId: 'chat_test-id', role: 'assistant', content: 'Hi there!', createdAt: new Date() },
];
let callCount = 0;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
if (callCount === 0) {
callCount++;
// First call: conversation query
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([conversationData]),
});
} else {
// Second call: messages query
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockResolvedValue(messageData),
}),
});
}
return chain;
});
const result = await chatEngine.getConversation('chat_test-id');
expect(result).not.toBeNull();
expect(result?.id).toBe('chat_test-id');
expect(result?.title).toBe('Test Conversation');
expect(result?.messages).toHaveLength(2);
});
it('should handle null model gracefully', async () => {
const conversationData = {
id: 'chat_no-model',
title: 'No Model',
model: null,
createdAt: new Date(),
updatedAt: new Date(),
};
let callCount = 0;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
if (callCount === 0) {
callCount++;
// First call: conversation query
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([conversationData]),
});
} else {
// Second call: messages query (empty)
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockResolvedValue([]),
}),
});
}
return chain;
});
const result = await chatEngine.getConversation('chat_no-model');
expect(result?.model).toBeUndefined();
});
});
describe('getRecentConversations', () => {
it('should return empty array when no conversations', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnValue({
...chain,
limit: vi.fn().mockResolvedValue([]),
}),
});
return chain;
});
const result = await chatEngine.getRecentConversations();
expect(result).toEqual([]);
});
it('should return conversations sorted by updatedAt', async () => {
const conversations = [
{ id: 'c1', title: 'Chat 1', model: 'claude-sonnet-4', createdAt: new Date(), updatedAt: new Date('2024-01-15') },
{ id: 'c2', title: 'Chat 2', model: 'gpt-4', createdAt: new Date(), updatedAt: new Date('2024-01-10') },
];
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnValue({
...chain,
limit: vi.fn().mockResolvedValue(conversations),
}),
});
return chain;
});
const result = await chatEngine.getRecentConversations();
expect(result).toHaveLength(2);
expect(result[0].id).toBe('c1');
});
it('should respect limit parameter', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnValue({
...chain,
limit: vi.fn((limitValue) => {
expect(limitValue).toBe(10);
return Promise.resolve([]);
}),
}),
});
return chain;
});
await chatEngine.getRecentConversations(10);
expect(mockLocalDb.select).toHaveBeenCalled();
});
it('should use default limit of 50', async () => {
let capturedLimit: number | undefined;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnValue({
...chain,
limit: vi.fn((limitValue) => {
capturedLimit = limitValue;
return Promise.resolve([]);
}),
}),
});
return chain;
});
await chatEngine.getRecentConversations();
expect(capturedLimit).toBe(50);
});
});
describe('updateConversation', () => {
it('should update conversation title', async () => {
let capturedUpdate: any;
vi.mocked(mockLocalDb.update).mockImplementation(() => ({
set: vi.fn((data) => {
capturedUpdate = data;
return {
where: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
await chatEngine.updateConversation('chat_123', { title: 'New Title' });
expect(capturedUpdate.title).toBe('New Title');
expect(capturedUpdate.updatedAt).toBeInstanceOf(Date);
});
it('should update conversation model', async () => {
let capturedUpdate: any;
vi.mocked(mockLocalDb.update).mockImplementation(() => ({
set: vi.fn((data) => {
capturedUpdate = data;
return {
where: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
await chatEngine.updateConversation('chat_123', { model: 'gpt-4-turbo' });
expect(capturedUpdate.model).toBe('gpt-4-turbo');
});
it('should always update updatedAt timestamp', async () => {
let capturedUpdate: any;
vi.mocked(mockLocalDb.update).mockImplementation(() => ({
set: vi.fn((data) => {
capturedUpdate = data;
return {
where: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
const before = new Date();
await chatEngine.updateConversation('chat_123', { title: 'Test' });
const after = new Date();
expect(capturedUpdate.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(capturedUpdate.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe('deleteConversation', () => {
it('should delete messages first', async () => {
const deleteCalls: string[] = [];
vi.mocked(mockLocalDb.delete).mockImplementation((table: any) => {
const tableName = table?.constructor?.name || 'messages';
deleteCalls.push(tableName);
return {
where: vi.fn().mockResolvedValue(undefined),
} as any;
});
await chatEngine.deleteConversation('chat_123');
// Should call delete twice (messages, then conversation)
expect(mockLocalDb.delete).toHaveBeenCalledTimes(2);
});
it('should delete the conversation', async () => {
await chatEngine.deleteConversation('chat_456');
expect(mockLocalDb.delete).toHaveBeenCalled();
});
});
describe('addMessage', () => {
it('should add user message', async () => {
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'user',
content: 'Hello, AI!',
createdAt: new Date(),
});
expect(result.role).toBe('user');
expect(result.content).toBe('Hello, AI!');
expect(result.id).toBeDefined();
});
it('should add assistant message', async () => {
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'assistant',
content: 'Hello! How can I help you?',
createdAt: new Date(),
});
expect(result.role).toBe('assistant');
expect(result.content).toBe('Hello! How can I help you?');
});
it('should add system message', async () => {
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'system',
content: 'You are a helpful assistant.',
createdAt: new Date(),
});
expect(result.role).toBe('system');
});
it('should add tool message with toolCallId', async () => {
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'tool',
content: '{"result": "success"}',
toolCallId: 'tool_call_abc123',
createdAt: new Date(),
});
expect(result.role).toBe('tool');
expect(result.toolCallId).toBe('tool_call_abc123');
});
it('should add message with toolCalls', async () => {
const toolCalls = JSON.stringify([{ id: 'tc1', type: 'function', function: { name: 'search', arguments: '{}' } }]);
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'assistant',
toolCalls,
createdAt: new Date(),
});
expect(result.toolCalls).toBe(toolCalls);
});
it('should update conversation updatedAt timestamp', async () => {
await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'user',
content: 'Test message',
createdAt: new Date(),
});
expect(mockLocalDb.update).toHaveBeenCalled();
});
it('should use provided createdAt or default to now', async () => {
const specificDate = new Date('2024-06-15T10:30:00Z');
const result = await chatEngine.addMessage({
conversationId: 'chat_123',
role: 'user',
content: 'Test',
createdAt: specificDate,
});
expect(result.createdAt).toEqual(specificDate);
});
});
describe('getMessages', () => {
it('should return empty array for conversation with no messages', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockResolvedValue([]),
}),
});
return chain;
});
const result = await chatEngine.getMessages('chat_empty');
expect(result).toEqual([]);
});
it('should return messages in chronological order', async () => {
const messages = [
{ id: 1, conversationId: 'chat_123', role: 'user', content: 'First', createdAt: new Date('2024-01-01') },
{ id: 2, conversationId: 'chat_123', role: 'assistant', content: 'Second', createdAt: new Date('2024-01-02') },
{ id: 3, conversationId: 'chat_123', role: 'user', content: 'Third', createdAt: new Date('2024-01-03') },
];
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockResolvedValue(messages),
}),
});
return chain;
});
const result = await chatEngine.getMessages('chat_123');
expect(result).toHaveLength(3);
expect(result[0].content).toBe('First');
expect(result[2].content).toBe('Third');
});
it('should handle null content gracefully', async () => {
const messages = [
{ id: 1, conversationId: 'chat_123', role: 'assistant', content: null, toolCalls: '[...]', createdAt: new Date() },
];
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockResolvedValue(messages),
}),
});
return chain;
});
const result = await chatEngine.getMessages('chat_123');
expect(result[0].content).toBeUndefined();
});
});
describe('clearMessages', () => {
it('should delete all messages for conversation', async () => {
await chatEngine.clearMessages('chat_123');
expect(mockLocalDb.delete).toHaveBeenCalled();
});
it('should only delete messages (not the conversation)', async () => {
let deleteCount = 0;
vi.mocked(mockLocalDb.delete).mockImplementation(() => {
deleteCount++;
return {
where: vi.fn().mockResolvedValue(undefined),
} as any;
});
await chatEngine.clearMessages('chat_123');
// Should only call delete once (for messages only)
expect(deleteCount).toBe(1);
});
});
describe('getDefaultSystemPrompt', () => {
it('should return saved prompt if exists', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([{ value: 'Custom system prompt' }]),
});
return chain;
});
const result = await chatEngine.getDefaultSystemPrompt();
expect(result).toBe('Custom system prompt');
});
it('should return built-in prompt when no saved prompt', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([]),
});
return chain;
});
const result = await chatEngine.getDefaultSystemPrompt();
expect(result).toContain('Blogging Desktop Server');
expect(result).toContain('Available Tools');
});
it('should return built-in prompt when saved prompt is empty', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([{ value: '' }]),
});
return chain;
});
const result = await chatEngine.getDefaultSystemPrompt();
expect(result).toContain('Blogging Desktop Server');
});
});
describe('setDefaultSystemPrompt', () => {
it('should save custom prompt', async () => {
let capturedValues: any;
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data) => {
capturedValues = data;
return {
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
await chatEngine.setDefaultSystemPrompt('My custom prompt');
expect(capturedValues.key).toBe('chat_system_prompt');
expect(capturedValues.value).toBe('My custom prompt');
});
it('should delete prompt when empty string provided', async () => {
await chatEngine.setDefaultSystemPrompt('');
expect(mockLocalDb.delete).toHaveBeenCalled();
});
it('should delete prompt when whitespace-only string provided', async () => {
await chatEngine.setDefaultSystemPrompt(' ');
expect(mockLocalDb.delete).toHaveBeenCalled();
});
});
describe('getSetting', () => {
it('should return setting value when exists', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([{ value: 'setting_value' }]),
});
return chain;
});
const result = await chatEngine.getSetting('my_setting');
expect(result).toBe('setting_value');
});
it('should return null when setting does not exist', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([]),
});
return chain;
});
const result = await chatEngine.getSetting('non_existent');
expect(result).toBeNull();
});
});
describe('setSetting', () => {
it('should insert or update setting', async () => {
let capturedValues: any;
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data) => {
capturedValues = data;
return {
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
await chatEngine.setSetting('my_key', 'my_value');
expect(capturedValues.key).toBe('my_key');
expect(capturedValues.value).toBe('my_value');
expect(capturedValues.updatedAt).toBeInstanceOf(Date);
});
});
describe('getSelectedModel', () => {
it('should return saved model when exists', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([{ value: 'gpt-4-turbo' }]),
});
return chain;
});
const result = await chatEngine.getSelectedModel();
expect(result).toBe('gpt-4-turbo');
});
it('should return default model when not set', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.from = vi.fn().mockReturnValue({
...chain,
where: vi.fn().mockResolvedValue([]),
});
return chain;
});
const result = await chatEngine.getSelectedModel();
expect(result).toBe('claude-sonnet-4');
});
});
describe('setSelectedModel', () => {
it('should save selected model', async () => {
let capturedValues: any;
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data) => {
capturedValues = data;
return {
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
}),
} as any));
await chatEngine.setSelectedModel('claude-opus-4');
expect(capturedValues.key).toBe('chat_model');
expect(capturedValues.value).toBe('claude-opus-4');
});
});
});