directus-task-management/tests/api/controllers/ai.controller.test.ts

442 lines
13 KiB
TypeScript

import 'reflect-metadata';
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
// Mock dependencies before importing controller
jest.mock('tsyringe', () => ({
container: {
resolve: jest.fn((token: string) => {
if (token === 'AITaskService') return mockAITaskService;
if (token === 'OpenAIService') return mockOpenAIService;
if (token === 'LangChainService') return mockLangChainService;
return null;
}),
},
injectable: jest.fn(() => (target: any) => target),
inject: jest.fn(() => (target: any, key: any, index: any) => {}),
}));
// Mock services
const mockAITaskService = {
createTaskFromNaturalLanguage: jest.fn(),
updateTaskContext: jest.fn(),
getTaskSuggestions: jest.fn(),
breakdownTask: jest.fn(),
getHealthStatus: jest.fn(),
getUsageStats: jest.fn(),
warmCache: jest.fn(),
};
const mockOpenAIService = {
testConnection: jest.fn(),
};
const mockLangChainService = {
testConnection: jest.fn(),
};
import { AIController } from '../../../src/api/controllers/ai.controller';
describe('AIController', () => {
let controller: AIController;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
jest.clearAllMocks();
controller = new AIController();
mockReq = {
body: {},
params: {},
query: {},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
mockNext = jest.fn();
});
describe('createTask', () => {
it('should create a task from natural language', async () => {
const mockTask = {
title: 'Implement login',
description: 'Add user authentication',
priority: 'high',
complexity: 'major',
};
mockAITaskService.createTaskFromNaturalLanguage.mockResolvedValue(mockTask);
mockReq.body = {
prompt: 'Create a login feature',
projectId: 'project-123',
};
await controller.createTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.createTaskFromNaturalLanguage).toHaveBeenCalledWith({
prompt: 'Create a login feature',
projectId: 'project-123',
context: undefined,
});
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: mockTask,
message: 'Task created successfully from natural language',
});
});
it('should handle validation errors', async () => {
mockReq.body = {
// Missing required 'prompt' field
projectId: 'project-123',
};
await controller.createTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Validation error',
details: expect.any(Array),
});
});
it('should handle rate limiting errors', async () => {
mockAITaskService.createTaskFromNaturalLanguage.mockRejectedValue(
new Error('Rate limit exceeded for task_creation')
);
mockReq.body = {
prompt: 'Create a task',
};
await controller.createTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(429);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Rate limit exceeded',
message: 'Too many requests. Please try again later.',
});
});
});
describe('updateTaskContext', () => {
it('should update task context with feedback', async () => {
mockAITaskService.updateTaskContext.mockResolvedValue(undefined);
mockReq.params = { taskId: 'task-123' };
mockReq.body = { feedback: 'Task needs more detail' };
await controller.updateTaskContext(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.updateTaskContext).toHaveBeenCalledWith(
'task-123',
'Task needs more detail'
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
message: 'Task context updated successfully',
});
});
it('should validate feedback is provided', async () => {
mockReq.params = { taskId: 'task-123' };
mockReq.body = {}; // Missing feedback
await controller.updateTaskContext(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Validation error',
details: expect.any(Array),
});
});
});
describe('getTaskSuggestions', () => {
it('should get task suggestions for a project', async () => {
const mockSuggestions = [
{
id: 'suggestion-1',
title: 'Add error handling',
description: 'Implement comprehensive error handling',
rationale: 'Improves user experience',
priority: 'medium',
confidence: 0.85,
},
];
mockAITaskService.getTaskSuggestions.mockResolvedValue(mockSuggestions);
mockReq.params = { projectId: 'project-123' };
mockReq.query = {
projectDescription: 'E-commerce platform',
completedTasks: 'Setup database,Create API',
currentTasks: 'Build UI',
goals: 'Launch MVP',
};
await controller.getTaskSuggestions(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.getTaskSuggestions).toHaveBeenCalledWith(
'project-123',
{
projectDescription: 'E-commerce platform',
completedTasks: ['Setup database', 'Create API'],
currentTasks: ['Build UI'],
goals: ['Launch MVP'],
}
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: mockSuggestions,
projectId: 'project-123',
});
});
it('should handle empty arrays for task lists', async () => {
mockAITaskService.getTaskSuggestions.mockResolvedValue([]);
mockReq.params = { projectId: 'project-456' };
mockReq.query = {
projectDescription: 'New project',
// No completed/current tasks or goals
};
await controller.getTaskSuggestions(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.getTaskSuggestions).toHaveBeenCalledWith(
'project-456',
{
projectDescription: 'New project',
completedTasks: [],
currentTasks: [],
goals: [],
}
);
});
});
describe('breakdownTask', () => {
it('should break down a task into subtasks', async () => {
const mockBreakdown = {
title: 'Main task',
description: 'Task description',
subtasks: [
{ id: '1', title: 'Subtask 1', description: 'First part' },
{ id: '2', title: 'Subtask 2', description: 'Second part' },
],
complexity: 'major',
estimatedTotalHours: 8,
};
mockAITaskService.breakdownTask.mockResolvedValue(mockBreakdown);
mockReq.params = { taskId: 'task-789' };
mockReq.body = {
taskDescription: 'Implement authentication system',
context: 'Web application using JWT',
};
await controller.breakdownTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.breakdownTask).toHaveBeenCalledWith(
'task-789',
'Implement authentication system',
'Web application using JWT'
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: mockBreakdown,
taskId: 'task-789',
});
});
});
describe('getHealthStatus', () => {
it('should return healthy status when all services are up', async () => {
mockAITaskService.getHealthStatus.mockResolvedValue({
openai: true,
langchain: true,
redis: true,
rateLimiter: true,
});
await controller.getHealthStatus(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
healthy: true,
services: {
openai: true,
langchain: true,
redis: true,
rateLimiter: true,
},
timestamp: expect.any(String),
});
});
it('should return 503 when services are down', async () => {
mockAITaskService.getHealthStatus.mockResolvedValue({
openai: false,
langchain: true,
redis: true,
rateLimiter: true,
});
await controller.getHealthStatus(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(503);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
healthy: false,
services: expect.any(Object),
timestamp: expect.any(String),
});
});
});
describe('getUsageStats', () => {
it('should return usage statistics', async () => {
const mockStats = {
openai: {
requestCount: 100,
totalTokensUsed: 50000,
estimatedCost: 1.0,
},
totalContextsSaved: 250,
cacheHitRate: 0.75,
};
mockAITaskService.getUsageStats.mockResolvedValue(mockStats);
await controller.getUsageStats(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
data: mockStats,
timestamp: expect.any(String),
});
});
});
describe('warmCache', () => {
it('should initiate cache warming', async () => {
mockAITaskService.warmCache.mockResolvedValue(undefined);
mockReq.body = { projectId: 'project-123' };
await controller.warmCache(mockReq as Request, mockRes as Response, mockNext);
expect(mockAITaskService.warmCache).toHaveBeenCalledWith('project-123');
expect(mockRes.status).toHaveBeenCalledWith(202);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
message: 'Cache warming initiated',
projectId: 'project-123',
});
});
it('should require projectId', async () => {
mockReq.body = {}; // Missing projectId
await controller.warmCache(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Project ID is required',
});
});
});
describe('testConnection', () => {
it('should test connections to AI services', async () => {
mockOpenAIService.testConnection.mockResolvedValue(true);
mockLangChainService.testConnection.mockResolvedValue(true);
await controller.testConnection(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
connections: {
openai: true,
langchain: true,
},
message: 'All AI services are connected',
});
});
it('should report when some services are unavailable', async () => {
mockOpenAIService.testConnection.mockResolvedValue(true);
mockLangChainService.testConnection.mockResolvedValue(false);
await controller.testConnection(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith({
success: true,
connections: {
openai: true,
langchain: false,
},
message: 'Some AI services are not available',
});
});
});
describe('error handling', () => {
it('should call next with non-validation errors', async () => {
const error = new Error('Database error');
mockAITaskService.createTaskFromNaturalLanguage.mockRejectedValue(error);
mockReq.body = { prompt: 'Create task' };
await controller.createTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(error);
});
it('should handle Zod validation errors with details', async () => {
mockReq.body = {
prompt: '', // Empty string should fail validation
};
await controller.createTask(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Validation error',
details: expect.arrayContaining([
expect.objectContaining({
path: expect.any(Array),
message: expect.any(String),
}),
]),
});
});
});
});