UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

924 lines (843 loc) 28.9 kB
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'); }); }); });