@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
733 lines (621 loc) • 25.1 kB
text/typescript
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'
});
});
});
});