@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
924 lines (843 loc) • 28.9 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setupAgileManagementTools } from '../tools.js';
import { SqliteManager } from '../../../storage/sqlite-manager.js';
// Mock SqliteManager
vi.mock('../../../storage/sqlite-manager.js', () => ({
SqliteManager: {
getInstance: vi.fn()
}
}));
describe('Agile Management Phase 2 Tools', () => {
let tools: any;
let mockDb: vi.Mocked<SqliteManager>;
beforeEach(async () => {
vi.clearAllMocks();
// Mock database operations
mockDb = {
run: vi.fn().mockResolvedValue({ success: true, changes: 1 }),
get: vi.fn().mockResolvedValue({ success: true, data: null }),
query: vi.fn().mockResolvedValue({ success: true, data: [] }),
transaction: vi.fn().mockImplementation(async (callback) => {
return await callback(mockDb);
})
} as any;
vi.mocked(SqliteManager.getInstance).mockReturnValue(mockDb);
// Setup tools
const toolRegistration = await setupAgileManagementTools();
tools = {};
for (const tool of toolRegistration.tools) {
tools[tool.name] = tool;
}
});
const createContext = (toolName: string) => ({
toolName,
requestId: 'test-req-1',
projectId: 'test-project',
userId: 'test-user',
timestamp: Date.now(),
db: mockDb
});
describe('Epic Management Tools', () => {
describe('get_epic_progress', () => {
it('should calculate epic progress based on story completion', async () => {
// Mock epic data
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'User Authentication Epic',
status: 'active',
story_points: 21
}
});
// Mock stories linked to epic
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{ id: 'story-1', title: 'Login', story_points: 5, status: 'done' },
{ id: 'story-2', title: 'Registration', story_points: 8, status: 'done' },
{ id: 'story-3', title: 'Password Reset', story_points: 3, status: 'in_progress' },
{ id: 'story-4', title: 'OAuth Integration', story_points: 5, status: 'todo' }
]
});
const result = await tools.get_epic_progress.execute(
{ epicId: 'epic-123' },
createContext('get_epic_progress')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'User Authentication Epic',
progress: {
completionPercentage: 50,
completedStories: 2,
totalStories: 4,
inProgressStories: 1,
blockedStories: 0
},
storyPoints: {
total: 21,
completed: 13,
completionPercentage: 62
}
});
});
it('should handle epic with no stories', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-empty',
title: 'Empty Epic',
status: 'planning'
}
});
mockDb.query.mockResolvedValueOnce({
success: true,
data: []
});
const result = await tools.get_epic_progress.execute(
{ epicId: 'epic-empty' },
createContext('get_epic_progress')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-empty',
progress: {
completionPercentage: 0,
completedStories: 0,
totalStories: 0
},
storyPoints: {
total: 0,
completed: 0,
completionPercentage: 0
}
});
});
it('should handle non-existent epic', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: null
});
const result = await tools.get_epic_progress.execute(
{ epicId: 'non-existent' },
createContext('get_epic_progress')
);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error.message).toContain('Epic not found');
});
});
describe('get_epic_timeline', () => {
it('should generate timeline with sprint breakdown', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Feature Epic',
status: 'active'
}
});
// Mock empty stories query then timeline data
mockDb.query
.mockResolvedValueOnce({ success: true, data: [] }) // Stories query
.mockResolvedValueOnce({ // Timeline query
success: true,
data: [
{
sprint_id: 'sprint-1',
sprint_name: 'Sprint 1',
start_date: new Date('2025-01-01').getTime() / 1000,
end_date: new Date('2025-01-14').getTime() / 1000,
sprint_status: 'completed',
total_stories: 1,
completed_stories: 1,
total_points: 5,
completed_points: 5
},
{
sprint_id: 'sprint-2',
sprint_name: 'Sprint 2',
start_date: new Date('2025-01-15').getTime() / 1000,
end_date: new Date('2025-01-28').getTime() / 1000,
sprint_status: 'active',
total_stories: 1,
completed_stories: 0,
total_points: 8,
completed_points: 0
}
]
});
const result = await tools.get_epic_timeline.execute(
{ epicId: 'epic-123' },
createContext('get_epic_timeline')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Feature Epic',
timeline: expect.any(Array),
summary: expect.objectContaining({
totalSprints: expect.any(Number)
})
});
// Since timeline depends on sprints having stories for this epic,
// and we didn't mock that relationship, timeline will be empty
expect(result.data.timeline).toEqual([]);
});
});
describe('close_epic', () => {
it('should close epic with validation', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Completed Epic',
status: 'active'
}
});
// Mock stories for sprint then stories for epic
mockDb.query
.mockResolvedValueOnce({ // Stories for sprint
success: true,
data: [
{ title: 'User login', story_points: 5, status: 'done' },
{ title: 'User profile', story_points: 8, status: 'done' }
]
})
.mockResolvedValueOnce({ // Blockers query
success: true,
data: [
{ story_title: 'Password reset', reason: 'API dependencies' }
]
});
const result = await tools.close_epic.execute(
{
epicId: 'epic-123',
completionNotes: 'All features implemented successfully',
businessValueRealized: 'Improved user onboarding by 40%',
closedBy: 'john.doe@example.com'
},
createContext('close_epic')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Completed Epic',
closedBy: 'john.doe@example.com',
completionMetrics: expect.objectContaining({
totalStories: 2,
completedStories: 2,
completionRate: 100
})
});
// Verify update was called
expect(mockDb.run).toHaveBeenCalled();
});
it('should fail to close epic with incomplete stories', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Incomplete Epic',
status: 'active'
}
});
// Mock stories with some incomplete
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{ id: 'story-1', status: 'done' },
{ id: 'story-2', status: 'in_progress' }
]
});
const result = await tools.close_epic.execute(
{
epicId: 'epic-123',
closedBy: 'john.doe@example.com'
},
createContext('close_epic')
);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error.message).toContain('Epic cannot be closed with only 50% completion');
});
});
describe('get_epic_stories', () => {
it('should list stories with filtering', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Test Epic',
status: 'active'
}
});
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{
id: 'story-1',
title: 'Story 1',
status: 'done',
story_points: 5,
assignee: 'dev1@example.com',
acceptance_criteria: '[]',
tags: '[]',
created_at: Math.floor(Date.now() / 1000),
updated_at: Math.floor(Date.now() / 1000),
sprint_id: null
},
{
id: 'story-2',
title: 'Story 2',
status: 'done',
story_points: 3,
assignee: 'dev2@example.com',
acceptance_criteria: '[]',
tags: '[]',
created_at: Math.floor(Date.now() / 1000),
updated_at: Math.floor(Date.now() / 1000),
sprint_id: null
}
]
});
const result = await tools.get_epic_stories.execute(
{
epicId: 'epic-123',
status: 'done'
},
createContext('get_epic_stories')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Test Epic',
stories: expect.any(Array),
summary: expect.objectContaining({
total: 2,
byStatus: expect.any(Object)
})
});
});
});
describe('validate_epic_requirements', () => {
it('should validate epic completeness', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Epic to Validate',
description: 'Test epic',
success_criteria: JSON.stringify(['Criterion 1', 'Criterion 2'])
}
});
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{ id: 'story-1', acceptance_criteria: JSON.stringify(['AC1', 'AC2']) },
{ id: 'story-2', acceptance_criteria: JSON.stringify(['AC3']) }
]
});
const result = await tools.validate_epic_requirements.execute(
{ epicId: 'epic-123' },
createContext('validate_epic_requirements')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Epic to Validate',
overallValid: false, // Not 80% complete
validationResults: expect.any(Array),
summary: expect.objectContaining({
totalChecks: expect.any(Number),
passed: expect.any(Number),
failed: expect.any(Number)
})
});
});
it('should identify validation issues', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Incomplete Epic',
description: null,
success_criteria: '[]'
}
});
mockDb.query.mockResolvedValueOnce({
success: true,
data: []
});
const result = await tools.validate_epic_requirements.execute(
{ epicId: 'epic-123' },
createContext('validate_epic_requirements')
);
expect(result.success).toBe(true);
expect(result.data.overallValid).toBe(false);
const failedChecks = result.data.validationResults.filter(r => !r.passed);
const failedMessages = failedChecks.map(r => r.message);
expect(failedMessages).toContain('Epic description is missing or empty');
expect(failedMessages).toContain('No success criteria defined');
expect(failedMessages).toContain('Epic has no linked stories');
});
});
describe('generate_epic_report', () => {
it('should generate comprehensive epic report', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Reporting Epic',
description: 'Epic for reporting',
status: 'active',
created_at: new Date('2025-01-01').getTime() / 1000
}
});
mockDb.query
.mockResolvedValueOnce({ // Stories
success: true,
data: [
{ status: 'done', story_points: 5 },
{ status: 'in_progress', story_points: 3 }
]
})
.mockResolvedValueOnce({ // Timeline data
success: true,
data: [
{ sprint_name: 'Sprint 1', story_count: 2, total_points: 8 }
]
});
const result = await tools.generate_epic_report.execute(
{ epicId: 'epic-123' },
createContext('generate_epic_report')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Reporting Epic',
epicStatus: 'active',
metrics: expect.objectContaining({
stories: expect.objectContaining({
total: 2,
completed: 1,
completionRate: 50
}),
storyPoints: expect.objectContaining({
total: 8,
completed: 5,
completionRate: 63
})
}),
timeline: expect.any(Array),
summary: expect.objectContaining({
readyForClosure: false,
keyMetrics: expect.any(Object)
})
});
});
});
});
describe('Advanced Reporting Tools', () => {
describe('generate_sprint_report', () => {
it('should generate comprehensive sprint analysis', async () => {
const sprintId = 'sprint-123';
// Mock sprint data
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: sprintId,
name: 'Sprint 10',
goal: 'Complete authentication features',
status: 'active',
story_points_planned: 21,
story_points_completed: 13,
start_date: new Date('2025-01-01').getTime() / 1000,
end_date: new Date('2025-01-14').getTime() / 1000
}
});
// Mock stories
mockDb.query
.mockResolvedValueOnce({
success: true,
data: [
{ status: 'done', story_points: 5, assignee: 'dev1' },
{ status: 'done', story_points: 8, assignee: 'dev2' },
{ status: 'in_progress', story_points: 3, assignee: 'dev1' },
{ status: 'todo', story_points: 5, assignee: null }
]
})
.mockResolvedValueOnce({ // Blockers
success: true,
data: [
{ story_id: 'story-3', reason: 'Waiting for API specs' }
]
});
const result = await tools.generate_sprint_report.execute(
{ sprintId },
createContext('generate_sprint_report')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
sprintId,
sprintName: 'Sprint 10',
sprintGoal: 'Complete authentication features',
storyMetrics: expect.objectContaining({
total: 4,
completed: 2,
completionRate: 50
}),
storyPointMetrics: expect.objectContaining({
total: 21,
completed: 13,
completionRate: 62
}),
velocityMetrics: expect.objectContaining({
planned: 21,
actual: 13,
percentage: 62
}),
blockerAnalysis: expect.objectContaining({
total: 1,
details: expect.arrayContaining([
expect.objectContaining({
reason: 'Waiting for API specs'
})
])
})
});
});
});
describe('get_team_velocity_trend', () => {
it('should analyze velocity trends over multiple sprints', async () => {
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{
id: 'sprint-1',
name: 'Sprint 1',
status: 'completed',
duration: 14,
start_date: new Date('2025-01-01').getTime() / 1000,
end_date: new Date('2025-01-14').getTime() / 1000,
total_stories: 5,
total_points: 25,
completed_points: 21
},
{
id: 'sprint-2',
name: 'Sprint 2',
status: 'completed',
duration: 14,
start_date: new Date('2025-01-15').getTime() / 1000,
end_date: new Date('2025-01-28').getTime() / 1000,
total_stories: 4,
total_points: 20,
completed_points: 18
},
{
id: 'sprint-3',
name: 'Sprint 3',
status: 'completed',
duration: 14,
start_date: new Date('2025-01-29').getTime() / 1000,
end_date: new Date('2025-02-11').getTime() / 1000,
total_stories: 6,
total_points: 30,
completed_points: 24
}
]
});
const result = await tools.get_team_velocity_trend.execute(
{ numberOfSprints: 3 },
createContext('get_team_velocity_trend')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
velocityTrend: expect.arrayContaining([
expect.objectContaining({
sprintId: 'sprint-1',
completedPoints: 21
})
]),
summary: expect.objectContaining({
averageVelocity: 21,
trendDirection: expect.any(String),
consistency: expect.any(Object)
})
});
});
});
describe('get_cycle_time_metrics', () => {
it('should analyze story lifecycle and identify bottlenecks', async () => {
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{
story_id: 'story-1',
previous_status: 'todo',
new_status: 'in_progress',
changed_at: new Date('2025-01-01').getTime() / 1000
},
{
story_id: 'story-1',
previous_status: 'in_progress',
new_status: 'review',
changed_at: new Date('2025-01-03').getTime() / 1000
},
{
story_id: 'story-1',
previous_status: 'review',
new_status: 'done',
changed_at: new Date('2025-01-04').getTime() / 1000
}
]
});
const result = await tools.get_cycle_time_metrics.execute(
{ dateRange: { start: '2024-12-01', end: '2025-01-04' } },
createContext('get_cycle_time_metrics')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
stories: expect.any(Array),
summary: expect.objectContaining({
totalStories: expect.any(Number),
completedStories: expect.any(Number),
averageCycleTimeDays: expect.any(Number),
bottlenecks: expect.any(Object)
})
});
});
});
describe('generate_retrospective_template', () => {
it('should generate retrospective template with sprint data', async () => {
const sprintId = 'sprint-123';
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: sprintId,
name: 'Sprint 10',
goal: 'Complete user features',
story_points_planned: 21,
story_points_completed: 18
}
});
mockDb.query
.mockResolvedValueOnce({ // Completed stories
success: true,
data: [
{ title: 'User login', story_points: 5, status: 'done' },
{ title: 'User profile', story_points: 8, status: 'done' }
]
})
.mockResolvedValueOnce({ // Blockers
success: true,
data: [
{ story_title: 'Password reset', reason: 'API dependencies' }
]
});
const result = await tools.generate_retrospective_template.execute(
{
sprintId,
templateType: 'starfish'
},
createContext('generate_retrospective_template')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
sprintId,
sprintName: 'Sprint 10',
templateType: 'starfish',
template: expect.objectContaining({
title: expect.any(String),
sections: expect.any(Array)
}),
metrics: expect.objectContaining({
totalStories: 2,
completedStories: 2,
totalStoryPoints: 13,
completedStoryPoints: 13
})
});
});
});
describe('generate_epic_burndown', () => {
it('should track epic progress over time', async () => {
mockDb.get.mockResolvedValueOnce({
success: true,
data: {
id: 'epic-123',
title: 'Feature Epic',
created_at: new Date('2025-01-01').getTime() / 1000
}
});
// Mock epic metrics
mockDb.query.mockResolvedValueOnce({
success: true,
data: [
{
calculation_date: new Date('2025-01-07').getTime() / 1000,
stories_total: 4,
stories_completed: 1,
story_points_total: 21,
story_points_completed: 5,
completion_percentage: 25
},
{
calculation_date: new Date('2025-01-14').getTime() / 1000,
stories_total: 4,
stories_completed: 2,
story_points_total: 21,
story_points_completed: 13,
completion_percentage: 50
}
]
});
const result = await tools.generate_epic_burndown.execute(
{ epicId: 'epic-123' },
createContext('generate_epic_burndown')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
epicId: 'epic-123',
epicTitle: 'Feature Epic',
chartData: expect.any(Array),
summary: expect.any(Object)
});
});
});
describe('get_cross_sprint_analytics', () => {
it('should analyze patterns across multiple sprints', async () => {
mockDb.query
.mockResolvedValueOnce({ // Sprint data
success: true,
data: [
{
id: 'sprint-1',
name: 'Sprint 1',
story_points_planned: 21,
story_points_completed: 18,
stories_total: 5,
stories_completed: 4
},
{
id: 'sprint-2',
name: 'Sprint 2',
story_points_planned: 24,
story_points_completed: 24,
stories_total: 6,
stories_completed: 6
}
]
})
.mockResolvedValueOnce({ // Story type distribution
success: true,
data: [
{ tag: 'feature', count: 8, total_points: 32 },
{ tag: 'bug', count: 3, total_points: 10 }
]
});
const result = await tools.get_cross_sprint_analytics.execute(
{ numberOfSprints: 2 },
createContext('get_cross_sprint_analytics')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
analysisRange: 2,
sprints: expect.any(Array),
analytics: expect.objectContaining({
sprintCount: 2,
velocityTrend: expect.any(Object),
storyTrend: expect.any(Object),
predictability: expect.any(Object)
})
});
});
});
describe('get_dependency_report', () => {
it('should analyze cross-story dependencies', async () => {
mockDb.query.mockResolvedValueOnce({ // Blocked stories
success: true,
data: [
{
blocker_id: 'blocker-1',
blocker_type: 'dependency',
reason: 'Waiting for API endpoints',
blocker_status: 'active',
resolution: null,
resolved_by: null,
resolved_at: null,
blocked_story_id: 'story-1',
blocked_story_title: 'API Integration',
blocked_story_status: 'blocked',
blocker_story_id: 'story-2',
blocker_story_title: 'API Development',
blocker_story_status: 'in_progress'
},
{
blocker_id: 'blocker-2',
blocker_type: 'external',
reason: 'Waiting for design approval',
blocker_status: 'active',
resolution: null,
resolved_by: null,
resolved_at: null,
blocked_story_id: 'story-3',
blocked_story_title: 'UI Updates',
blocked_story_status: 'blocked',
blocker_story_id: null,
blocker_story_title: null,
blocker_story_status: null
}
]
});
const result = await tools.get_dependency_report.execute(
{},
createContext('get_dependency_report')
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
dependencies: expect.arrayContaining([
expect.objectContaining({
type: 'dependency',
reason: 'Waiting for API endpoints',
blockedStory: expect.objectContaining({
title: 'API Integration'
})
})
]),
analysis: expect.objectContaining({
total: 2,
active: 2,
byType: expect.any(Object),
criticalPath: expect.any(Array)
})
});
});
});
});
describe('Error Handling', () => {
it('should handle database errors gracefully', async () => {
// Mock get to throw an error
mockDb.get.mockImplementation(() => {
throw new Error('Database connection failed');
});
const result = await tools.get_epic_progress.execute(
{ epicId: 'epic-123' },
createContext('get_epic_progress')
);
expect(result.success).toBe(false);
expect(result.error.message).toContain('Failed to get epic progress');
});
it('should validate required parameters', async () => {
const result = await tools.get_epic_progress.execute(
{}, // Missing epicId
createContext('get_epic_progress')
);
expect(result.success).toBe(false);
expect(result.error.message).toBeDefined();
expect(result.error.category).toBe('validation');
});
});
});