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)
This commit is contained in:
498
src/dispatch/phase-manager.test.ts
Normal file
498
src/dispatch/phase-manager.test.ts
Normal file
@@ -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<T extends DomainEvent>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user