368 lines
16 KiB
JavaScript
368 lines
16 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
require("reflect-metadata");
|
|
const ai_task_service_1 = require("../../../src/services/ai/ai-task.service");
|
|
// 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;
|
|
let mockOpenAIService;
|
|
let mockLangChainService;
|
|
let mockRedis;
|
|
let mockRepository;
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
// Mock repository
|
|
mockRepository = {
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
count: jest.fn().mockResolvedValue(10),
|
|
};
|
|
// Mock getRepository
|
|
require('typeorm').getRepository.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,
|
|
}),
|
|
};
|
|
mockLangChainService = {
|
|
createTaskFromNaturalLanguage: jest.fn(),
|
|
breakdownTask: jest.fn(),
|
|
getTaskSuggestions: jest.fn(),
|
|
testConnection: jest.fn().mockResolvedValue(true),
|
|
};
|
|
mockRedis = {
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
del: jest.fn(),
|
|
keys: jest.fn().mockResolvedValue([]),
|
|
ping: jest.fn().mockResolvedValue('PONG'),
|
|
};
|
|
// Create service instance
|
|
service = new ai_task_service_1.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',
|
|
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',
|
|
};
|
|
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.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');
|
|
});
|
|
});
|
|
});
|
|
//# sourceMappingURL=ai-task.service.test.js.map
|