From 1aac486f48a64ef65f63c31bf37c238d57c7c490 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Mon, 2 Feb 2026 13:48:35 +0100 Subject: [PATCH] test(14-07): add DefaultPhaseDispatchManager tests - Add queuePhase tests (add to queue, emit event, include deps) - Add getNextDispatchablePhase tests (empty, no deps first, skip incomplete, oldest) - Add dispatchNextPhase tests (update status, emit event, remove from queue) - Add completePhase tests (update status, remove from queue, emit event) - Add blockPhase tests (update status, add to blocked, emit event) - Add dependency scenario test (diamond dependency pattern) --- src/dispatch/phase-manager.test.ts | 498 +++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 src/dispatch/phase-manager.test.ts diff --git a/src/dispatch/phase-manager.test.ts b/src/dispatch/phase-manager.test.ts new file mode 100644 index 0000000..0c4cb18 --- /dev/null +++ b/src/dispatch/phase-manager.test.ts @@ -0,0 +1,498 @@ +/** + * 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 { 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'; + +// ============================================================================= +// 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 +// ============================================================================= + +describe('DefaultPhaseDispatchManager', () => { + let db: DrizzleDatabase; + let phaseRepository: DrizzlePhaseRepository; + let initiativeRepository: DrizzleInitiativeRepository; + let eventBus: EventBus & { emittedEvents: DomainEvent[] }; + let phaseDispatchManager: DefaultPhaseDispatchManager; + let testInitiativeId: string; + + beforeEach(async () => { + // Set up test database + db = createTestDatabase(); + phaseRepository = new DrizzlePhaseRepository(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 + eventBus = createMockEventBus(); + + // Create phase dispatch manager + phaseDispatchManager = new DefaultPhaseDispatchManager( + phaseRepository, + eventBus + ); + }); + + // =========================================================================== + // queuePhase() Tests + // =========================================================================== + + describe('queuePhase', () => { + it('should add phase to queue', async () => { + const phase = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Phase 1', + }); + const phase2 = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 2, + name: 'Phase 2', + }); + + // 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' + ); + }); + }); + + // =========================================================================== + // 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, + number: 1, + name: 'Phase 1 (no deps)', + }); + const phase2 = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 2, + name: 'Phase 2 (depends on 1)', + }); + + // 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, + number: 1, + name: 'Phase 1', + status: 'pending', // Not completed + }); + const phase2 = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 2, + name: 'Phase 2', + }); + + // 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, + number: 1, + name: 'Phase 1', + }); + const phase2 = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 2, + name: 'Phase 2', + }); + + // 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Test Phase', + }); + + 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, + number: 1, + name: 'Phase A - Foundation', + }); + const phaseB = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 2, + name: 'Phase B - Build on A', + }); + const phaseC = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 3, + name: 'Phase C - Also build on A', + }); + const phaseD = await phaseRepository.create({ + initiativeId: testInitiativeId, + number: 4, + name: 'Phase D - Needs B and C', + }); + + // 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); + }); + }); +});