/** * DefaultPhaseDispatchManager Tests * * Tests for the PhaseDispatchManager adapter with dependency checking * and queue management. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DefaultPhaseDispatchManager } from './phase-manager.js'; import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js'; import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js'; import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js'; import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js'; import type { DrizzleDatabase } from '../db/index.js'; import type { EventBus, DomainEvent } from '../events/types.js'; import type { DispatchManager } from './types.js'; // ============================================================================= // Test Helpers // ============================================================================= /** * Create a mock EventBus that captures emitted events. */ function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } { const emittedEvents: DomainEvent[] = []; return { emittedEvents, emit(event: T): void { emittedEvents.push(event); }, on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } // ============================================================================= // Tests // ============================================================================= /** * Create a mock DispatchManager (stub, not used in phase dispatch tests). */ function createMockDispatchManager(): DispatchManager { return { queue: vi.fn(), getNextDispatchable: vi.fn().mockResolvedValue(null), dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }), completeTask: vi.fn(), approveTask: vi.fn(), blockTask: vi.fn(), getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }), }; } describe('DefaultPhaseDispatchManager', () => { let db: DrizzleDatabase; let phaseRepository: DrizzlePhaseRepository; let taskRepository: DrizzleTaskRepository; let initiativeRepository: DrizzleInitiativeRepository; let eventBus: EventBus & { emittedEvents: DomainEvent[] }; let dispatchManager: DispatchManager; let phaseDispatchManager: DefaultPhaseDispatchManager; let testInitiativeId: string; beforeEach(async () => { // Set up test database db = createTestDatabase(); phaseRepository = new DrizzlePhaseRepository(db); taskRepository = new DrizzleTaskRepository(db); initiativeRepository = new DrizzleInitiativeRepository(db); // Create required initiative for phases const initiative = await initiativeRepository.create({ name: 'Test Initiative', }); testInitiativeId = initiative.id; // Create mock event bus and dispatch manager eventBus = createMockEventBus(); dispatchManager = createMockDispatchManager(); // Create phase dispatch manager phaseDispatchManager = new DefaultPhaseDispatchManager( phaseRepository, taskRepository, dispatchManager, eventBus ); }); // =========================================================================== // queuePhase() Tests // =========================================================================== describe('queuePhase', () => { it('should add phase to queue', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.queued.length).toBe(1); expect(state.queued[0].phaseId).toBe(phase.id); expect(state.queued[0].initiativeId).toBe(testInitiativeId); }); it('should emit PhaseQueuedEvent', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); // Check event was emitted expect(eventBus.emittedEvents.length).toBe(1); expect(eventBus.emittedEvents[0].type).toBe('phase:queued'); expect((eventBus.emittedEvents[0] as any).payload.phaseId).toBe(phase.id); expect((eventBus.emittedEvents[0] as any).payload.initiativeId).toBe(testInitiativeId); }); it('should include dependencies in queued phase', async () => { const phase1 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 1', }); const phase2 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 2', }); await phaseRepository.update(phase2.id, { status: 'approved' as const }); // Phase 2 depends on Phase 1 await phaseRepository.createDependency(phase2.id, phase1.id); await phaseDispatchManager.queuePhase(phase2.id); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.queued[0].dependsOn).toContain(phase1.id); // Event should also include dependencies expect((eventBus.emittedEvents[0] as any).payload.dependsOn).toContain(phase1.id); }); it('should throw error when phase not found', async () => { await expect(phaseDispatchManager.queuePhase('non-existent-id')).rejects.toThrow( 'Phase not found' ); }); it('should reject non-approved phase', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Pending Phase', status: 'pending', }); await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow( 'must be approved before queuing' ); }); it('should reject in_progress phase', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'In Progress Phase', status: 'in_progress', }); await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow( 'must be approved before queuing' ); }); }); // =========================================================================== // getNextDispatchablePhase() Tests // =========================================================================== describe('getNextDispatchablePhase', () => { it('should return null when queue empty', async () => { const next = await phaseDispatchManager.getNextDispatchablePhase(); expect(next).toBeNull(); }); it('should return phase with no dependencies first', async () => { const phase1 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 1 (no deps)', }); const phase2 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 2 (depends on 1)', }); await phaseRepository.update(phase1.id, { status: 'approved' as const }); await phaseRepository.update(phase2.id, { status: 'approved' as const }); // Phase 2 depends on Phase 1 await phaseRepository.createDependency(phase2.id, phase1.id); // Queue both phases (phase 2 first, then phase 1) await phaseDispatchManager.queuePhase(phase2.id); await phaseDispatchManager.queuePhase(phase1.id); // Should return phase 1 since phase 2 has incomplete dependency const next = await phaseDispatchManager.getNextDispatchablePhase(); expect(next).not.toBeNull(); expect(next!.phaseId).toBe(phase1.id); }); it('should skip phases with incomplete dependencies', async () => { const phase1 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 1', status: 'pending', // Not completed }); const phase2 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 2', }); await phaseRepository.update(phase2.id, { status: 'approved' as const }); // Phase 2 depends on Phase 1 await phaseRepository.createDependency(phase2.id, phase1.id); // Queue only phase 2 (phase 1 is not queued or completed) await phaseDispatchManager.queuePhase(phase2.id); // Should return null since phase 2's dependency (phase 1) is not complete const next = await phaseDispatchManager.getNextDispatchablePhase(); expect(next).toBeNull(); }); it('should return oldest phase when multiple ready', async () => { const phase1 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 1', }); const phase2 = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase 2', }); await phaseRepository.update(phase1.id, { status: 'approved' as const }); await phaseRepository.update(phase2.id, { status: 'approved' as const }); // Queue phase1 first, then phase2 await phaseDispatchManager.queuePhase(phase1.id); await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay await phaseDispatchManager.queuePhase(phase2.id); // Should return phase 1 (queued first) const next = await phaseDispatchManager.getNextDispatchablePhase(); expect(next).not.toBeNull(); expect(next!.phaseId).toBe(phase1.id); }); }); // =========================================================================== // dispatchNextPhase() Tests // =========================================================================== describe('dispatchNextPhase', () => { it('should update phase status to in_progress', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); const result = await phaseDispatchManager.dispatchNextPhase(); expect(result.success).toBe(true); expect(result.phaseId).toBe(phase.id); // Check phase status updated const updatedPhase = await phaseRepository.findById(phase.id); expect(updatedPhase!.status).toBe('in_progress'); }); it('should emit PhaseStartedEvent', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.dispatchNextPhase(); // Find PhaseStartedEvent (events include queued + started) const startedEvent = eventBus.emittedEvents.find( (e) => e.type === 'phase:started' ); expect(startedEvent).toBeDefined(); expect((startedEvent as any).payload.phaseId).toBe(phase.id); expect((startedEvent as any).payload.initiativeId).toBe(testInitiativeId); }); it('should return failure when no dispatchable phases', async () => { const result = await phaseDispatchManager.dispatchNextPhase(); expect(result.success).toBe(false); expect(result.reason).toBe('No dispatchable phases'); }); it('should remove phase from queue after dispatch', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.dispatchNextPhase(); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.queued.length).toBe(0); }); }); // =========================================================================== // completePhase() Tests // =========================================================================== describe('completePhase', () => { it('should update phase status to completed', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', status: 'in_progress', }); await phaseDispatchManager.completePhase(phase.id); const updatedPhase = await phaseRepository.findById(phase.id); expect(updatedPhase!.status).toBe('completed'); }); it('should remove from queue', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.completePhase(phase.id); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.queued.length).toBe(0); }); it('should emit PhaseCompletedEvent', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', status: 'in_progress', }); await phaseDispatchManager.completePhase(phase.id); // Find PhaseCompletedEvent const completedEvent = eventBus.emittedEvents.find( (e) => e.type === 'phase:completed' ); expect(completedEvent).toBeDefined(); expect((completedEvent as any).payload.phaseId).toBe(phase.id); expect((completedEvent as any).payload.initiativeId).toBe(testInitiativeId); expect((completedEvent as any).payload.success).toBe(true); }); it('should throw error when phase not found', async () => { await expect(phaseDispatchManager.completePhase('non-existent-id')).rejects.toThrow( 'Phase not found' ); }); }); // =========================================================================== // blockPhase() Tests // =========================================================================== describe('blockPhase', () => { it('should update phase status', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input'); const updatedPhase = await phaseRepository.findById(phase.id); expect(updatedPhase!.status).toBe('blocked'); }); it('should add to blocked list', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input'); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.blocked.length).toBe(1); expect(state.blocked[0].phaseId).toBe(phase.id); expect(state.blocked[0].reason).toBe('Waiting for user input'); }); it('should emit PhaseBlockedEvent', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.blockPhase(phase.id, 'External dependency'); // Find PhaseBlockedEvent (events include queued + blocked) const blockedEvent = eventBus.emittedEvents.find( (e) => e.type === 'phase:blocked' ); expect(blockedEvent).toBeDefined(); expect((blockedEvent as any).payload.phaseId).toBe(phase.id); expect((blockedEvent as any).payload.reason).toBe('External dependency'); }); it('should remove from queue when blocked', async () => { const phase = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Test Phase', }); await phaseRepository.update(phase.id, { status: 'approved' as const }); await phaseDispatchManager.queuePhase(phase.id); await phaseDispatchManager.blockPhase(phase.id, 'Some reason'); const state = await phaseDispatchManager.getPhaseQueueState(); expect(state.queued.length).toBe(0); }); }); // =========================================================================== // Dependency Scenario Test // =========================================================================== describe('dependency scenario', () => { it('should dispatch phases in correct dependency order', async () => { // Create a diamond dependency: // Phase A (no deps) -> Phase B & C -> Phase D (depends on B and C) const phaseA = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase A - Foundation', }); const phaseB = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase B - Build on A', }); const phaseC = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase C - Also build on A', }); const phaseD = await phaseRepository.create({ initiativeId: testInitiativeId, name: 'Phase D - Needs B and C', }); await phaseRepository.update(phaseA.id, { status: 'approved' as const }); await phaseRepository.update(phaseB.id, { status: 'approved' as const }); await phaseRepository.update(phaseC.id, { status: 'approved' as const }); await phaseRepository.update(phaseD.id, { status: 'approved' as const }); // Set up dependencies await phaseRepository.createDependency(phaseB.id, phaseA.id); await phaseRepository.createDependency(phaseC.id, phaseA.id); await phaseRepository.createDependency(phaseD.id, phaseB.id); await phaseRepository.createDependency(phaseD.id, phaseC.id); // Queue all four phases await phaseDispatchManager.queuePhase(phaseA.id); await phaseDispatchManager.queuePhase(phaseB.id); await phaseDispatchManager.queuePhase(phaseC.id); await phaseDispatchManager.queuePhase(phaseD.id); // Initially only A should be dispatchable let state = await phaseDispatchManager.getPhaseQueueState(); expect(state.ready.length).toBe(1); expect(state.ready[0].phaseId).toBe(phaseA.id); // Dispatch A let result = await phaseDispatchManager.dispatchNextPhase(); expect(result.success).toBe(true); expect(result.phaseId).toBe(phaseA.id); // Complete A await phaseDispatchManager.completePhase(phaseA.id); // Now B and C should be dispatchable (both have A completed) state = await phaseDispatchManager.getPhaseQueueState(); expect(state.ready.length).toBe(2); const readyIds = state.ready.map((p) => p.phaseId); expect(readyIds).toContain(phaseB.id); expect(readyIds).toContain(phaseC.id); // D should still not be ready (needs both B and C completed) expect(readyIds).not.toContain(phaseD.id); // Dispatch and complete B result = await phaseDispatchManager.dispatchNextPhase(); expect(result.success).toBe(true); await phaseDispatchManager.completePhase(result.phaseId!); // D still not ready (needs C completed too) state = await phaseDispatchManager.getPhaseQueueState(); expect(state.ready.length).toBe(1); // Only C is ready now // Dispatch and complete C result = await phaseDispatchManager.dispatchNextPhase(); expect(result.success).toBe(true); await phaseDispatchManager.completePhase(result.phaseId!); // Now D should be ready state = await phaseDispatchManager.getPhaseQueueState(); expect(state.ready.length).toBe(1); expect(state.ready[0].phaseId).toBe(phaseD.id); }); }); });