UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

733 lines (621 loc) 25.1 kB
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import express, { Application } from 'express'; import request from 'supertest'; import { setupAgileAPI } from '../../api/agile.js'; import { AgileManager } from '../../../modules/agile-management/agile-manager.js'; // Mock the SQLite manager vi.mock('../../../storage/sqlite-manager.js', () => ({ getSQLiteManager: vi.fn(), ensureDatabaseReady: vi.fn() })); // Mock the AgileManager vi.mock('../../../modules/agile-management/agile-manager.js', () => ({ AgileManager: vi.fn() })); describe('Agile API', () => { let app: Application; let mockAgileManager: any; let mockSQLiteManager: any; beforeEach(async () => { app = express(); app.use(express.json()); // Setup mock SQLite manager mockSQLiteManager = { query: vi.fn(), get: vi.fn(), run: vi.fn(), all: vi.fn(), // Add the missing all method initialize: vi.fn(), close: vi.fn() }; const { getSQLiteManager, ensureDatabaseReady } = await import('../../../storage/sqlite-manager.js'); vi.mocked(getSQLiteManager).mockReturnValue(mockSQLiteManager as any); vi.mocked(ensureDatabaseReady).mockResolvedValue(mockSQLiteManager as any); // Create mock agile manager with all required methods mockAgileManager = { // Sprint methods getAllSprints: vi.fn(), getSprintById: vi.fn(), getActiveSprint: vi.fn(), // Story methods getAllStories: vi.fn(), getStoriesForSprint: vi.fn(), getStoriesBySprint: vi.fn(), getStoriesByStatus: vi.fn(), updateStory: vi.fn(), getBacklog: vi.fn(), // Epic methods getAllEpics: vi.fn(), getEpicById: vi.fn(), // Velocity and metrics calculateVelocity: vi.fn(), getBurndownData: vi.fn(), getSprintMetrics: vi.fn(), // Board management getBoards: vi.fn(), getBoardById: vi.fn(), updateBoard: vi.fn(), createBoard: vi.fn(), // Advanced features getBacklogItems: vi.fn(), prioritizeBacklog: vi.fn(), estimateStory: vi.fn(), getTeamCapacity: vi.fn(), generateSprintReport: vi.fn() }; // Mock constructor (AgileManager as any).mockImplementation(() => mockAgileManager); // Setup the API setupAgileAPI(app, mockAgileManager); }); afterEach(() => { vi.clearAllMocks(); }); describe('GET /api/agile/sprints', () => { it('should return all sprints', async () => { const mockSprintData = [ { id: 'sprint-1', name: 'Sprint 1', goal: 'Complete MVP', status: 'active', start_date: new Date('2024-01-01').getTime(), end_date: new Date('2024-01-14').getTime(), duration: 14, team: JSON.stringify(['dev1', 'dev2']), story_points_planned: 20, story_points_completed: 15, stories_total: 5, stories_completed: 3, velocity: 15, created_at: Date.now(), updated_at: Date.now() } ]; mockSQLiteManager.query.mockResolvedValue({ success: true, data: mockSprintData }); const response = await request(app).get('/api/agile/sprints'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('sprints'); expect(response.body.data.sprints).toHaveLength(1); expect(response.body.data.sprints[0].id).toBe('sprint-1'); expect(response.body.data.sprints[0].name).toBe('Sprint 1'); }); it('should handle errors gracefully', async () => { mockSQLiteManager.query.mockResolvedValue({ success: false, error: 'Database connection failed' }); const response = await request(app).get('/api/agile/sprints'); // API now returns 503 when database fails (no more mock fallbacks) expect(response.status).toBe(503); expect(response.body.success).toBe(false); expect(response.body.error).toBe('Database unavailable'); }); it('should return empty array when no sprints exist', async () => { mockSQLiteManager.query.mockResolvedValue({ success: true, data: [] }); const response = await request(app).get('/api/agile/sprints'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('sprints'); expect(response.body.data.sprints).toEqual([]); }); }); describe('GET /api/agile/sprints/active', () => { it('should return the active sprint', async () => { const mockActiveSprint = { id: 'sprint-2', name: 'Sprint 2', status: 'active', goal: 'Implement user authentication', start_date: new Date('2024-01-01').getTime(), end_date: new Date('2024-01-14').getTime(), duration: 14, team: JSON.stringify(['dev1', 'dev2']), created_at: Date.now(), updated_at: Date.now() }; mockSQLiteManager.get.mockResolvedValue({ success: true, data: mockActiveSprint }); const response = await request(app).get('/api/agile/sprints/active'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.id).toBe('sprint-2'); expect(response.body.data.name).toBe('Sprint 2'); }); it('should return 404 when no active sprint exists', async () => { mockSQLiteManager.get.mockResolvedValue({ success: true, data: null }); const response = await request(app).get('/api/agile/sprints/active'); expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.error).toBe('No active sprint found'); }); }); describe('GET /api/agile/sprints/:id', () => { it('should return a specific sprint with proper field mapping', async () => { // Mock raw database sprint data (snake_case as in DB) const mockDbSprint = { id: 'sprint-test-123', project_id: 'default', name: 'Test Sprint', goal: 'Complete test features', status: 'active', start_date: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago end_date: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days from now duration: 14, team: '["Dev1", "Dev2"]', story_points_planned: 20, story_points_completed: 10, stories_total: 5, stories_completed: 2, velocity: 10, created_at: Date.now() - 10 * 24 * 60 * 60 * 1000, updated_at: Date.now() }; // Mock the database queries mockSQLiteManager.get .mockResolvedValueOnce({ success: true, data: { count: 1 } }) // EXISTS check .mockResolvedValueOnce({ success: true, data: mockDbSprint }); // Sprint query mockSQLiteManager.all.mockResolvedValue({ success: true, data: [] // No stories for simplicity }); mockSQLiteManager.run.mockResolvedValue({ success: true }); const response = await request(app).get('/api/agile/sprints/sprint-test-123'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); // Verify field mapping from snake_case to camelCase const sprint = response.body.data; expect(sprint.id).toBe('sprint-test-123'); expect(sprint.name).toBe('Test Sprint'); expect(sprint.status).toBe('active'); expect(sprint.startDate).toBe(new Date(mockDbSprint.start_date).toISOString()); expect(sprint.endDate).toBe(new Date(mockDbSprint.end_date).toISOString()); expect(sprint.duration).toBe(14); expect(sprint.team).toEqual(['Dev1', 'Dev2']); expect(sprint.storyPointsPlanned).toBe(20); // These values are recalculated based on stories (which we mocked as empty) expect(sprint.storyPointsCompleted).toBe(0); // Recalculated from empty stories expect(sprint.storiesTotal).toBe(0); // Recalculated from empty stories expect(sprint.storiesCompleted).toBe(0); // Recalculated from empty stories expect(sprint.velocity).toBe(0); // Recalculated from completed points }); it('should return 404 when sprint does not exist', async () => { // Mock database returning no sprint mockSQLiteManager.get .mockResolvedValueOnce({ success: true, data: { count: 0 } }) // EXISTS check .mockResolvedValueOnce({ success: true, data: undefined }); // No sprint found const response = await request(app).get('/api/agile/sprints/non-existent-sprint'); expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.error).toBe('Sprint not found'); expect(response.body.sprintId).toBe('non-existent-sprint'); }); it('should return 404 for various invalid sprint IDs', async () => { // Test various invalid sprint ID formats const invalidSprintIds = [ 'invalid-id', 'sprint-999999', 'SPRINT-UPPERCASE', 'sprint_with_underscores', 'sprint-' + 'x'.repeat(50), // Very long ID 'sprint-<script>alert("xss")</script>', // XSS attempt 'sprint-../../../etc/passwd', // Path traversal attempt 'sprint-null', 'sprint-undefined', ' ', // Whitespace 'sprint-0', 'sprint--double-dash', 'sprint-!@#$%^&*()', // Special characters ]; for (const invalidId of invalidSprintIds) { // Reset mocks for each test mockSQLiteManager.get.mockReset(); mockSQLiteManager.get .mockResolvedValueOnce({ success: true, data: { count: 0 } }) // EXISTS check .mockResolvedValueOnce({ success: true, data: undefined }); // No sprint found const response = await request(app).get(`/api/agile/sprints/${encodeURIComponent(invalidId)}`); expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.error).toBe('Sprint not found'); expect(response.body.sprintId).toBe(invalidId); } }); it('should handle empty string ID by routing to list endpoint', async () => { // Test that empty string routes to the list endpoint // When ID is empty, the URL becomes /api/agile/sprints/ which matches the list endpoint mockSQLiteManager.query.mockResolvedValueOnce({ success: true, data: [] }); const response = await request(app).get('/api/agile/sprints/'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.sprints).toEqual([]); }); it('should handle database errors gracefully', async () => { // Mock database error mockSQLiteManager.get.mockRejectedValue(new Error('Database connection failed')); const response = await request(app).get('/api/agile/sprints/sprint-1'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); expect(response.body.error).toBe('Failed to fetch sprint details'); }); it('should handle SQL injection attempts safely', async () => { // Test SQL injection attempts const sqlInjectionAttempts = [ "'; DROP TABLE agile_sprints; --", "' OR '1'='1", "sprint-1' UNION SELECT * FROM users--", "sprint-1'; DELETE FROM agile_sprints WHERE '1'='1", ]; for (const maliciousId of sqlInjectionAttempts) { mockSQLiteManager.get.mockReset(); mockSQLiteManager.get .mockResolvedValueOnce({ success: true, data: { count: 0 } }) .mockResolvedValueOnce({ success: true, data: undefined }); const response = await request(app).get(`/api/agile/sprints/${encodeURIComponent(maliciousId)}`); expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.error).toBe('Sprint not found'); // Verify the SQL query was called with the malicious ID as a parameter (safely) expect(mockSQLiteManager.get).toHaveBeenCalledWith( expect.stringContaining('WHERE id = ?'), [maliciousId] ); } }); // Regression test for the specific 404 bug it('should find sprints that exist in the list endpoint', async () => { // First, verify sprint exists in list const listData = [ { id: 'sprint-bug-test', name: 'Bug Test Sprint', status: 'active', // ... other fields } ]; mockSQLiteManager.query.mockResolvedValueOnce({ success: true, data: [{ id: 'sprint-bug-test', name: 'Bug Test Sprint', status: 'active', start_date: Date.now(), end_date: Date.now() + 14 * 24 * 60 * 60 * 1000, team: '[]', story_points_planned: 0, story_points_completed: 0, stories_total: 0, stories_completed: 0, velocity: 0, created_at: Date.now(), updated_at: Date.now() }] }); // Get list of sprints const listResponse = await request(app).get('/api/agile/sprints'); expect(listResponse.status).toBe(200); expect(listResponse.body.data.sprints).toHaveLength(1); expect(listResponse.body.data.sprints[0].id).toBe('sprint-bug-test'); // Now verify individual sprint endpoint finds the same sprint mockSQLiteManager.get .mockResolvedValueOnce({ success: true, data: { count: 1 } }) .mockResolvedValueOnce({ success: true, data: { id: 'sprint-bug-test', name: 'Bug Test Sprint', status: 'active', start_date: Date.now(), end_date: Date.now() + 14 * 24 * 60 * 60 * 1000, team: '[]', story_points_planned: 0, story_points_completed: 0, stories_total: 0, stories_completed: 0, velocity: 0, created_at: Date.now(), updated_at: Date.now() } }); mockSQLiteManager.all.mockResolvedValueOnce({ success: true, data: [] }); mockSQLiteManager.run.mockResolvedValue({ success: true }); // This should NOT return 404 const individualResponse = await request(app).get('/api/agile/sprints/sprint-bug-test'); expect(individualResponse.status).toBe(200); expect(individualResponse.body.success).toBe(true); expect(individualResponse.body.data.id).toBe('sprint-bug-test'); }); }); describe('GET /api/agile/stories', () => { it('should return filtered stories', async () => { const mockStories = [ { id: 'story-1', title: 'User login', status: 'in_progress', priority: 'high', storyPoints: 5 }, { id: 'story-2', title: 'User logout', status: 'done', priority: 'low', storyPoints: 3 } ]; mockAgileManager.getAllStories.mockResolvedValue(mockStories); const response = await request(app) .get('/api/agile/stories') .query({ status: 'in_progress', priority: 'high' }); expect(response.status).toBe(200); expect(response.body.data).toHaveProperty('stories'); expect(response.body.data.stories).toHaveLength(1); expect(response.body.data.stories[0]).toEqual(mockStories[0]); expect(mockAgileManager.getAllStories).toHaveBeenCalled(); }); it('should return all stories when no filters provided', async () => { const mockStories = [ { id: 'story-1', title: 'Story 1' }, { id: 'story-2', title: 'Story 2' } ]; mockAgileManager.getAllStories.mockResolvedValue(mockStories); const response = await request(app).get('/api/agile/stories'); expect(response.status).toBe(200); expect(response.body.data).toHaveProperty('stories'); expect(response.body.data.stories).toEqual(mockStories); expect(mockAgileManager.getAllStories).toHaveBeenCalled(); }); }); describe('PUT /api/agile/stories/:id', () => { it('should update a story', async () => { const updateData = { status: 'done', hoursSpent: 8 }; mockAgileManager.updateStory.mockResolvedValue({ id: 'story-1', ...updateData }); const response = await request(app) .put('/api/agile/stories/story-1') .send(updateData); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(mockAgileManager.updateStory).toHaveBeenCalledWith('story-1', updateData); }); }); describe('GET /api/agile/velocity', () => { it('should return velocity metrics', async () => { // Mock completed sprints - returned in DESC order by end_date const mockSprints = [ { id: 'sprint-2', name: 'Sprint 2', status: 'completed', story_points_completed: 25, end_date: new Date('2024-01-28').getTime() / 1000 }, { id: 'sprint-1', name: 'Sprint 1', status: 'completed', story_points_completed: 20, end_date: new Date('2024-01-14').getTime() / 1000 } ]; // Mock stories for velocity calculation const mockStoriesSprint1 = [ { story_points: 5, status: 'done' }, { story_points: 8, status: 'done' }, { story_points: 7, status: 'done' } ]; const mockStoriesSprint2 = [ { story_points: 10, status: 'done' }, { story_points: 15, status: 'done' } ]; // Mock database calls // First call returns sprints in DESC order (newest first) mockSQLiteManager.all .mockResolvedValueOnce({ success: true, data: mockSprints }) // After reversal, sprints are processed as [Sprint 1, Sprint 2] .mockResolvedValueOnce({ success: true, data: mockStoriesSprint1 }) // Sprint 1 stories first .mockResolvedValueOnce({ success: true, data: mockStoriesSprint2 }); // Sprint 2 stories second const response = await request(app) .get('/api/agile/velocity') .query({ teamName: 'Alpha Team', sprints: '5' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); expect(response.body.data.labels).toEqual(['Sprint 1', 'Sprint 2']); // Reversed to show oldest first expect(response.body.data.completedPoints).toEqual([20, 25]); // Reversed order expect(response.body.data.averageVelocity).toBe(23); // (20 + 25) / 2 expect(response.body.data.currentVelocity).toBe(25); // Last sprint's velocity }); }); describe('GET /api/agile/burndown/:sprintId', () => { it('should return burndown chart data', async () => { const mockSprint = { id: 'sprint-1', name: 'Sprint 1', status: 'active', start_date: new Date('2024-01-01').getTime() / 1000, end_date: new Date('2024-01-14').getTime() / 1000 }; const mockStories = [ { id: 'story-1', story_points: 8, status: 'done', updated_at: new Date('2024-01-05').getTime() / 1000 }, { id: 'story-2', story_points: 5, status: 'done', updated_at: new Date('2024-01-08').getTime() / 1000 }, { id: 'story-3', story_points: 10, status: 'in-progress', updated_at: new Date('2024-01-10').getTime() / 1000 }, { id: 'story-4', story_points: 7, status: 'todo', updated_at: new Date('2024-01-01').getTime() / 1000 } ]; // Mock database calls mockSQLiteManager.get.mockResolvedValueOnce({ success: true, data: mockSprint }); mockSQLiteManager.all.mockResolvedValueOnce({ success: true, data: mockStories }); const response = await request(app).get('/api/agile/burndown/sprint-1'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); expect(response.body.data.sprintId).toBe('sprint-1'); expect(response.body.data.sprintName).toBe('Sprint 1'); expect(response.body.data.totalStoryPoints).toBe(30); // 8 + 5 + 10 + 7 expect(response.body.data.completedPoints).toBe(13); // 8 + 5 expect(response.body.data.labels).toBeDefined(); expect(response.body.data.idealBurndown).toBeDefined(); expect(response.body.data.actualBurndown).toBeDefined(); }); }); describe('GET /api/agile/boards', () => { it('should return all boards', async () => { const mockBoards = [ { id: 'board-1', name: 'Development Board', columns: ['To Do', 'In Progress', 'Done'] } ]; mockAgileManager.getBoards.mockResolvedValue(mockBoards); const response = await request(app).get('/api/agile/boards'); expect(response.status).toBe(200); expect(response.body.data).toEqual(mockBoards); }); }); describe('POST /api/agile/boards', () => { it('should create a new board', async () => { const newBoard = { name: 'QA Board', columns: ['Ready for QA', 'Testing', 'Verified'] }; mockAgileManager.createBoard.mockResolvedValue({ id: 'board-2', ...newBoard }); const response = await request(app) .post('/api/agile/boards') .send(newBoard); expect(response.status).toBe(201); expect(response.body.success).toBe(true); expect(mockAgileManager.createBoard).toHaveBeenCalledWith(newBoard); }); it('should validate required fields', async () => { const response = await request(app) .post('/api/agile/boards') .send({ columns: ['Test'] }); // missing name expect(response.status).toBe(400); expect(response.body.error).toBe('Board name and columns are required'); }); }); describe('GET /api/agile/backlog', () => { it('should return prioritized backlog items', async () => { const mockBacklog = [ { id: 'story-10', title: 'High priority feature', priority: 'high', storyPoints: 8 } ]; mockAgileManager.getBacklogItems.mockResolvedValue(mockBacklog); const response = await request(app) .get('/api/agile/backlog') .query({ includeEstimates: 'true', maxItems: '20' }); expect(response.status).toBe(200); expect(response.body.data).toEqual(mockBacklog); expect(mockAgileManager.getBacklogItems).toHaveBeenCalledWith({ includeEstimates: true, maxItems: 20 }); }); }); describe('POST /api/agile/estimate', () => { it('should estimate a story', async () => { const estimateData = { storyId: 'story-15', estimates: { dev1: 5, dev2: 8, dev3: 5 }, finalEstimate: 5 }; mockAgileManager.estimateStory.mockResolvedValue({ storyId: 'story-15', estimate: 5, confidence: 85 }); const response = await request(app) .post('/api/agile/estimate') .send(estimateData); expect(response.status).toBe(200); expect(mockAgileManager.estimateStory).toHaveBeenCalledWith( 'story-15', estimateData ); }); }); describe('GET /api/agile/capacity/:sprintId', () => { it('should return team capacity for sprint', async () => { const mockCapacity = { totalHours: 320, totalStoryPoints: 40, teamMembers: [] }; mockAgileManager.getTeamCapacity.mockResolvedValue(mockCapacity); const response = await request(app).get('/api/agile/capacity/sprint-1'); expect(response.status).toBe(200); expect(response.body.data).toEqual(mockCapacity); }); }); describe('GET /api/agile/report/:sprintId', () => { it('should generate sprint report', async () => { const mockReport = { sprint: { id: 'sprint-1', name: 'Sprint 1' }, metrics: { velocity: 25, completionRate: 85 }, highlights: ['Completed authentication'], issues: ['Technical debt in payment module'] }; mockAgileManager.generateSprintReport.mockResolvedValue(mockReport); const response = await request(app) .get('/api/agile/report/sprint-1') .query({ includeMetrics: 'true', format: 'detailed' }); expect(response.status).toBe(200); expect(response.body.data).toEqual(mockReport); expect(mockAgileManager.generateSprintReport).toHaveBeenCalledWith('sprint-1', { includeMetrics: true, includeStories: true, includeRetrospective: false, format: 'detailed' }); }); }); });