directus-task-management/tests/services/ai/ai-task.service.test.ts

491 lines
15 KiB
TypeScript

import 'reflect-metadata';
import { AITaskService } from '../../../src/services/ai/ai-task.service';
import { OpenAIService } from '../../../src/services/ai/openai.service';
import { LangChainService } from '../../../src/services/ai/langchain.service';
import { AIConfig } from '../../../src/config/ai.config';
import { Repository } from 'typeorm';
import Redis from 'ioredis';
// Mock dependencies
jest.mock('../../../src/services/ai/openai.service');
jest.mock('../../../src/services/ai/langchain.service');
jest.mock('typeorm', () => ({
getRepository: jest.fn(),
Entity: jest.fn(),
PrimaryGeneratedColumn: jest.fn(),
Column: jest.fn(),
CreateDateColumn: jest.fn(),
UpdateDateColumn: jest.fn(),
Index: jest.fn(),
}));
jest.mock('ioredis');
jest.mock('rate-limiter-flexible');
describe('AITaskService', () => {
let service: AITaskService;
let mockOpenAIService: jest.Mocked<OpenAIService>;
let mockLangChainService: jest.Mocked<LangChainService>;
let mockRedis: jest.Mocked<Redis>;
let mockRepository: jest.Mocked<Repository<any>>;
beforeEach(() => {
jest.clearAllMocks();
// Mock repository
mockRepository = {
create: jest.fn(),
save: jest.fn(),
count: jest.fn().mockResolvedValue(10),
} as any;
// Mock getRepository
(require('typeorm').getRepository as jest.Mock).mockReturnValue(mockRepository);
// Create mock services
mockOpenAIService = {
complete: jest.fn(),
testConnection: jest.fn().mockResolvedValue(true),
getUsageStats: jest.fn().mockReturnValue({
requestCount: 5,
totalTokensUsed: 1000,
estimatedCost: 0.02,
}),
} as any;
mockLangChainService = {
createTaskFromNaturalLanguage: jest.fn(),
breakdownTask: jest.fn(),
getTaskSuggestions: jest.fn(),
testConnection: jest.fn().mockResolvedValue(true),
} as any;
mockRedis = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
keys: jest.fn().mockResolvedValue([]),
ping: jest.fn().mockResolvedValue('PONG'),
} as any;
// Create service instance
service = new AITaskService(
mockOpenAIService,
mockLangChainService,
mockRedis,
);
});
describe('createTaskFromNaturalLanguage', () => {
it('should create a task from natural language', async () => {
const mockTask = {
title: 'Fix login bug',
description: 'Users cannot login with email',
priority: 'high',
complexity: 'minor',
estimatedHours: 2,
executionTimeMs: 150,
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockRedis.get.mockResolvedValue(null); // No cache hit
const result = await service.createTaskFromNaturalLanguage({
prompt: 'Fix the bug where users cant login with email',
projectId: 'project-123',
});
expect(result).toMatchObject({
title: 'Fix login bug',
description: 'Users cannot login with email',
priority: 'high',
complexity: 'minor',
});
expect(mockLangChainService.createTaskFromNaturalLanguage).toHaveBeenCalledWith(
'Fix the bug where users cant login with email',
'',
{ temperature: 0.5 },
);
// Should cache the result
expect(mockRedis.set).toHaveBeenCalled();
});
it('should enhance task with acceptance criteria if missing', async () => {
const mockTask = {
title: 'Add feature',
description: 'New feature implementation',
priority: 'medium',
complexity: 'major',
acceptanceCriteria: [],
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockOpenAIService.complete.mockResolvedValue({
content: '1. Feature works correctly\n2. Tests pass\n3. Documentation updated',
promptTokens: 50,
completionTokens: 30,
totalTokens: 80,
model: 'gpt-4-turbo',
finishReason: 'stop',
executionTimeMs: 200,
});
mockRedis.get.mockResolvedValue(null);
const result = await service.createTaskFromNaturalLanguage({
prompt: 'Add new feature',
});
expect(result.acceptanceCriteria).toEqual([
'Feature works correctly',
'Tests pass',
'Documentation updated',
]);
expect(mockOpenAIService.complete).toHaveBeenCalled();
});
it('should use cached result if available', async () => {
const cachedTask = {
title: 'Cached task',
description: 'From cache',
priority: 'low',
complexity: 'trivial',
};
mockRedis.get.mockResolvedValue(JSON.stringify(cachedTask));
const result = await service.createTaskFromNaturalLanguage({
prompt: 'Create a task',
});
expect(result).toEqual(cachedTask);
expect(mockLangChainService.createTaskFromNaturalLanguage).not.toHaveBeenCalled();
});
it('should save AI context on success', async () => {
const mockTask = {
title: 'New task',
description: 'Task description',
priority: 'medium',
complexity: 'minor',
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockRedis.get.mockResolvedValue(null);
await service.createTaskFromNaturalLanguage({
prompt: 'Create new task',
});
expect(mockRepository.create).toHaveBeenCalled();
expect(mockRepository.save).toHaveBeenCalled();
});
it('should save error context on failure', async () => {
mockLangChainService.createTaskFromNaturalLanguage.mockRejectedValue(
new Error('AI service error'),
);
mockRedis.get.mockResolvedValue(null);
await expect(service.createTaskFromNaturalLanguage({
prompt: 'Create task',
})).rejects.toThrow('AI service error');
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
context_type: 'error',
success: false,
}),
);
});
});
describe('breakdownTask', () => {
it('should breakdown a task into subtasks', async () => {
const mockBreakdown = {
title: 'Main task',
description: 'Task to break down',
subtasks: [
{ id: '1', title: 'Subtask 1', description: 'First subtask' },
{ id: '2', title: 'Subtask 2', description: 'Second subtask' },
],
complexity: 'major' as const,
estimatedTotalHours: 8,
};
mockLangChainService.breakdownTask.mockResolvedValue(mockBreakdown);
mockRedis.get.mockResolvedValue(null);
const result = await service.breakdownTask(
'task-123',
'Implement authentication system',
'Web application',
);
expect(result).toEqual(mockBreakdown);
expect(mockLangChainService.breakdownTask).toHaveBeenCalledWith(
'Implement authentication system',
'Web application',
{ temperature: 0.3, maxTokens: 2000 },
);
// Should cache the result
expect(mockRedis.set).toHaveBeenCalledWith(
expect.stringContaining('ai:breakdown:task-123'),
JSON.stringify(mockBreakdown),
'EX',
7200,
);
});
it('should use cached breakdown if available', async () => {
const cachedBreakdown = {
title: 'Cached breakdown',
subtasks: [],
complexity: 'minor' as const,
};
mockRedis.get.mockResolvedValue(JSON.stringify(cachedBreakdown));
const result = await service.breakdownTask('task-456', 'Task description');
expect(result).toEqual(cachedBreakdown);
expect(mockLangChainService.breakdownTask).not.toHaveBeenCalled();
});
});
describe('getTaskSuggestions', () => {
it('should generate task suggestions for a project', async () => {
const mockSuggestionsText = `
1. Implement user profile page - Users need to view and edit their profiles
2. Add password reset functionality - Security requirement for production
3. Setup email notifications - Keep users informed about important events
`;
mockLangChainService.getTaskSuggestions.mockResolvedValue(mockSuggestionsText);
mockRedis.get.mockResolvedValue(null);
const result = await service.getTaskSuggestions('project-123', {
projectDescription: 'User management system',
completedTasks: ['User registration', 'Login'],
currentTasks: ['Dashboard'],
goals: ['Complete MVP'],
});
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({
id: 'suggestion-1',
title: 'Implement user profile page',
priority: 'medium',
confidence: 0.7,
});
expect(mockLangChainService.getTaskSuggestions).toHaveBeenCalledWith(
{
projectDescription: 'User management system',
completedTasks: ['User registration', 'Login'],
currentTasks: ['Dashboard'],
goals: ['Complete MVP'],
},
{ temperature: 0.7, maxTokens: 1500 },
);
});
it('should parse suggestions with different formats', async () => {
const mockSuggestionsText = `
1) First task - Description here
2. Second task - Another description
3) Third task
`;
mockLangChainService.getTaskSuggestions.mockResolvedValue(mockSuggestionsText);
mockRedis.get.mockResolvedValue(null);
const result = await service.getTaskSuggestions('project-456', {
projectDescription: 'Test project',
completedTasks: [],
currentTasks: [],
goals: [],
});
expect(result).toHaveLength(3);
expect(result[0].title).toBe('First task');
expect(result[1].title).toBe('Second task');
expect(result[2].title).toBe('Third task');
});
});
describe('updateTaskContext', () => {
it('should update task context with feedback', async () => {
await service.updateTaskContext('task-789', 'Task needs more detail');
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
task_id: 'task-789',
context_type: 'feedback',
context_data: expect.objectContaining({
feedback: 'Task needs more detail',
}),
}),
);
expect(mockRepository.save).toHaveBeenCalled();
// Should invalidate cache
expect(mockRedis.keys).toHaveBeenCalledWith('ai:breakdown:task-789');
});
});
describe('warmCache', () => {
it('should pre-populate cache with common queries', async () => {
const mockTask = {
title: 'Generic task',
description: 'Task description',
priority: 'medium',
complexity: 'minor',
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockRedis.get.mockResolvedValue(null);
await service.warmCache('project-123');
// Should attempt to cache common prompts
expect(mockLangChainService.createTaskFromNaturalLanguage).toHaveBeenCalledTimes(4);
expect(mockLangChainService.createTaskFromNaturalLanguage).toHaveBeenCalledWith(
expect.stringContaining('bug fix'),
expect.any(String),
expect.any(Object),
);
});
it('should handle errors during cache warming', async () => {
mockLangChainService.createTaskFromNaturalLanguage.mockRejectedValue(
new Error('Service unavailable'),
);
mockRedis.get.mockResolvedValue(null);
// Should not throw
await expect(service.warmCache('project-456')).resolves.not.toThrow();
});
});
describe('getHealthStatus', () => {
it('should return health status of all services', async () => {
mockOpenAIService.testConnection.mockResolvedValue(true);
mockLangChainService.testConnection.mockResolvedValue(true);
const health = await service.getHealthStatus();
expect(health).toEqual({
openai: true,
langchain: true,
redis: true,
rateLimiter: true,
});
});
it('should handle service failures', async () => {
mockOpenAIService.testConnection.mockResolvedValue(false);
mockLangChainService.testConnection.mockResolvedValue(true);
mockRedis.ping.mockRejectedValue(new Error('Connection failed'));
const health = await service.getHealthStatus();
expect(health).toEqual({
openai: false,
langchain: true,
redis: false,
rateLimiter: true,
});
});
});
describe('getUsageStats', () => {
it('should return combined usage statistics', async () => {
const stats = await service.getUsageStats();
expect(stats).toEqual({
openai: {
requestCount: 5,
totalTokensUsed: 1000,
estimatedCost: 0.02,
},
totalContextsSaved: 10,
cacheHitRate: 0.0,
});
expect(mockOpenAIService.getUsageStats).toHaveBeenCalled();
expect(mockRepository.count).toHaveBeenCalled();
});
});
describe('rate limiting', () => {
it('should enforce rate limits', async () => {
// Mock rate limiter to throw on second call
const mockRateLimiter = {
consume: jest.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Rate limit exceeded')),
};
(service as any).rateLimiter = mockRateLimiter;
const mockTask = {
title: 'Task',
description: 'Description',
priority: 'medium',
complexity: 'minor',
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockRedis.get.mockResolvedValue(null);
// First call should succeed
await service.createTaskFromNaturalLanguage({ prompt: 'First task' });
// Second call should fail with rate limit
await expect(service.createTaskFromNaturalLanguage({
prompt: 'Second task',
})).rejects.toThrow('Rate limit exceeded');
});
});
describe('caching', () => {
it('should handle cache errors gracefully', async () => {
mockRedis.get.mockRejectedValue(new Error('Redis error'));
mockRedis.set.mockRejectedValue(new Error('Redis error'));
const mockTask = {
title: 'Task',
description: 'Description',
priority: 'medium',
complexity: 'minor',
};
mockLangChainService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
// Should still work despite cache errors
const result = await service.createTaskFromNaturalLanguage({
prompt: 'Create task',
});
expect(result).toEqual(mockTask);
});
it('should invalidate cache patterns', async () => {
mockRedis.keys.mockResolvedValue([
'ai:breakdown:task-123',
'ai:breakdown:task-123:v2',
]);
await service.updateTaskContext('task-123', 'Feedback');
expect(mockRedis.del).toHaveBeenCalledWith(
'ai:breakdown:task-123',
'ai:breakdown:task-123:v2',
);
});
});
});