import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { PhaseService } from '../../src/domain/services/phase-service.js';
import { PlanService } from '../../src/domain/services/plan-service.js';
import { RepositoryFactory } from '../../src/infrastructure/factory/repository-factory.js';
import { FileLockManager } from '../../src/infrastructure/repositories/file/file-lock-manager.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('PhaseService', () => {
let service: PhaseService;
let planService: PlanService;
let repositoryFactory: RepositoryFactory;
let lockManager: FileLockManager;
let testDir: string;
let planId: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `mcp-phase-test-${Date.now().toString()}`);
lockManager = new FileLockManager(testDir);
await lockManager.initialize();
repositoryFactory = new RepositoryFactory({
type: 'file',
baseDir: testDir,
lockManager,
cacheOptions: { enabled: true, ttl: 5000, maxSize: 1000 }
});
const planRepo = repositoryFactory.createPlanRepository();
await planRepo.initialize();
planService = new PlanService(repositoryFactory);
service = new PhaseService(repositoryFactory, planService);
const plan = await planService.createPlan({
name: 'Test Plan',
description: 'For testing phases',
});
planId = plan.planId;
});
afterEach(async () => {
await repositoryFactory.dispose();
await lockManager.dispose();
await fs.rm(testDir, { recursive: true, force: true });
});
describe('get_phase', () => {
it('should get phase by id', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'A test phase',
objectives: ['Test objective'],
deliverables: ['Test deliverable'],
successCriteria: ['Test passes'],
},
});
const result = await service.getPhase({
planId,
phaseId: added.phaseId,
fields: ['*'],
});
expect(result.phase).toBeDefined();
expect(result.phase.id).toBe(added.phaseId);
expect(result.phase.title).toBe('Test Phase');
expect(result.phase.description).toBe('A test phase');
});
it('should throw error for non-existent phase', async () => {
await expect(
service.getPhase({
planId,
phaseId: 'non-existent-id',
})
).rejects.toThrow('Phase not found');
});
it('should throw error for non-existent plan', async () => {
await expect(
service.getPhase({
planId: 'non-existent-plan',
phaseId: 'some-id',
})
).rejects.toThrow();
});
});
describe('add_phase', () => {
// RED: Validation tests for REQUIRED fields
describe('title validation (REQUIRED field)', () => {
it('RED: should reject missing title (undefined)', async () => {
await expect(service.addPhase({
planId,
phase: {
// @ts-expect-error - Testing invalid input
title: undefined,
description: 'Test phase',
objectives: [],
deliverables: [],
},
})).rejects.toThrow('title is required');
});
it('RED: should reject empty title', async () => {
await expect(service.addPhase({
planId,
phase: {
title: '',
description: 'Test phase',
objectives: [],
deliverables: [],
},
})).rejects.toThrow('title must be a non-empty string');
});
it('RED: should reject whitespace-only title', async () => {
await expect(service.addPhase({
planId,
phase: {
title: ' ',
description: 'Test phase',
objectives: [],
deliverables: [],
},
})).rejects.toThrow('title must be a non-empty string');
});
});
// GREEN: Tests for minimal phase with defaults
describe('minimal phase with defaults', () => {
it('GREEN: should accept minimal phase (title only)', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Implementation Phase',
},
});
expect(result.phaseId).toBeDefined();
// Verify defaults were applied
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId, fields: ['*'] });
expect(phase.title).toBe('Implementation Phase');
expect(phase.description).toBe(''); // default
expect(phase.objectives).toEqual([]); // default
expect(phase.deliverables).toEqual([]); // default
expect(phase.successCriteria).toEqual([]); // default
expect(phase.priority).toBe('medium'); // default
expect(phase.status).toBe('planned');
});
});
it('should add a root phase', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Build auth'],
deliverables: ['Login API'],
successCriteria: ['Tests pass'],
},
});
expect(result.phaseId).toBeDefined();
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.depth).toBe(0);
expect(phase.path).toBe('1');
expect(phase.status).toBe('planned');
});
it('should add a nested phase', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: 'Parent phase',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: 'Child phase',
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: child.phaseId });
expect(phase.depth).toBe(1);
expect(phase.path).toBe('1.1');
expect(phase.parentId).toBe(parent.phaseId);
});
it('should auto-increment order', async () => {
await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const p2 = await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: p2.phaseId });
expect(phase.order).toBe(2);
expect(phase.path).toBe('2');
});
});
describe('get_phase_tree', () => {
beforeEach(async () => {
const p1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.addPhase({
planId,
phase: {
title: 'Phase 1.1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: p1.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'Phase 1.2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: p1.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
});
it('should return full tree', async () => {
const result = await service.getPhaseTree({ planId });
expect(result.tree).toHaveLength(2); // Phase 1 and Phase 2
expect(result.tree[0].children).toHaveLength(2); // Phase 1.1 and 1.2
expect(result.tree[0].hasChildren).toBe(true);
});
it('should exclude completed if requested', async () => {
// Mark Phase 2 as completed
const tree1 = await service.getPhaseTree({ planId });
const phase2Id = tree1.tree[1].phase.id;
await service.updatePhaseStatus({
planId,
phaseId: phase2Id,
status: 'completed',
});
const result = await service.getPhaseTree({ planId, includeCompleted: false });
expect(result.tree).toHaveLength(1);
});
});
describe('priority field', () => {
it('should save priority=critical when provided', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Critical Phase',
description: 'High priority work',
objectives: ['Critical objective'],
deliverables: ['Critical deliverable'],
successCriteria: ['Tests pass'],
priority: 'critical',
},
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.priority).toBe('critical');
});
it('should save each priority value correctly', async () => {
const priorities: ('critical' | 'high' | 'medium' | 'low')[] =
['critical', 'high', 'medium', 'low'];
for (const prio of priorities) {
const result = await service.addPhase({
planId,
phase: {
title: `${prio} Priority`,
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: prio,
},
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.priority).toBe(prio);
}
});
it('should default to medium when priority not provided', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'No Priority',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
},
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.priority).toBe('medium');
});
it('should reject invalid priority value', async () => {
await expect(
service.addPhase({
planId,
phase: {
title: 'Invalid',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'urgent' as unknown as 'critical',
},
})
).rejects.toThrow(/Invalid priority/);
});
it('should update priority from low to critical', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'low',
},
});
await service.updatePhase({
planId,
phaseId: added.phaseId,
updates: { priority: 'critical' },
});
const { phase } = await service.getPhase({ planId, phaseId: added.phaseId });
expect(phase.priority).toBe('critical');
});
it('should preserve priority when updating status', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'high',
},
});
await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'in_progress',
});
const { phase } = await service.getPhase({ planId, phaseId: added.phaseId });
expect(phase.priority).toBe('high');
});
});
describe('update_phase_status', () => {
it('should auto-set startedAt on in_progress', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'in_progress',
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: added.phaseId });
expect(phase.startedAt).toBeDefined();
});
it('should auto-set completedAt and progress on completed', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'completed',
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: added.phaseId });
expect(phase.completedAt).toBeDefined();
expect(phase.progress).toBe(100);
});
it('should require notes for blocked status', async () => {
const phase = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await expect(
service.updatePhaseStatus({
planId,
phaseId: phase.phaseId,
status: 'blocked',
})
).rejects.toThrow('Notes required');
});
it('should accept notes for blocked status', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'blocked',
notes: 'Waiting for API access',
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: added.phaseId });
expect(phase.status).toBe('blocked');
});
it('should track actual effort', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'completed',
actualEffort: 4.5,
});
// Verify via getPhase
const { phase } = await service.getPhase({
planId,
phaseId: added.phaseId,
fields: ['*'],
});
expect(phase.schedule.actualEffort).toBe(4.5);
});
});
describe('get_next_actions with priority', () => {
it('should sort planned phases by priority: critical > high > medium > low', async () => {
const low = await service.addPhase({
planId,
phase: {
title: 'Low',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'low',
},
});
const critical = await service.addPhase({
planId,
phase: {
title: 'Critical',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'critical',
},
});
const medium = await service.addPhase({
planId,
phase: {
title: 'Medium',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'medium',
},
});
const high = await service.addPhase({
planId,
phase: {
title: 'High',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'high',
},
});
const result = await service.getNextActions({ planId, limit: 10 });
const plannedActions = result.actions.filter(a => a.action === 'start');
expect(plannedActions[0].phaseId).toBe(critical.phaseId);
expect(plannedActions[1].phaseId).toBe(high.phaseId);
expect(plannedActions[2].phaseId).toBe(medium.phaseId);
expect(plannedActions[3].phaseId).toBe(low.phaseId);
});
it('should prioritize status over priority (blocked-low before in_progress-critical)', async () => {
const blocked = await service.addPhase({
planId,
phase: {
title: 'Blocked Low',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'low',
},
});
await service.updatePhaseStatus({
planId,
phaseId: blocked.phaseId,
status: 'blocked',
notes: 'Waiting',
});
const inProgress = await service.addPhase({
planId,
phase: {
title: 'InProgress Critical',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'critical',
},
});
await service.updatePhaseStatus({
planId,
phaseId: inProgress.phaseId,
status: 'in_progress',
});
const result = await service.getNextActions({ planId, limit: 10 });
// Status priority: blocked > in_progress
expect(result.actions[0].phaseId).toBe(blocked.phaseId);
expect(result.actions[1].phaseId).toBe(inProgress.phaseId);
});
it('should sort blocked phases by priority', async () => {
const blockedLow = await service.addPhase({
planId,
phase: {
title: 'Blocked Low',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'low',
},
});
await service.updatePhaseStatus({
planId,
phaseId: blockedLow.phaseId,
status: 'blocked',
notes: 'Waiting',
});
const blockedCritical = await service.addPhase({
planId,
phase: {
title: 'Blocked Critical',
description: 'Test',
objectives: ['T'],
deliverables: ['T'],
successCriteria: ['T'],
priority: 'critical',
},
});
await service.updatePhaseStatus({
planId,
phaseId: blockedCritical.phaseId,
status: 'blocked',
notes: 'Waiting',
});
const result = await service.getNextActions({ planId, limit: 10 });
const blockedActions = result.actions.filter(a => a.action === 'unblock');
expect(blockedActions[0].phaseId).toBe(blockedCritical.phaseId);
expect(blockedActions[1].phaseId).toBe(blockedLow.phaseId);
});
});
describe('delete_phase', () => {
it('should delete single phase', async () => {
const phase = await service.addPhase({
planId,
phase: {
title: 'To Delete',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const result = await service.deletePhase({
planId,
phaseId: phase.phaseId,
});
expect(result.success).toBe(true);
expect(result.deletedPhaseIds).toHaveLength(1);
});
it('should delete with children', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.addPhase({
planId,
phase: {
title: 'Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const result = await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: true,
});
expect(result.deletedPhaseIds).toHaveLength(2);
const tree = await service.getPhaseTree({ planId });
expect(tree.tree).toHaveLength(0);
});
// Sprint 8: Bug #17 - Orphan Phases Fix
describe('orphan phases handling (Bug #17)', () => {
it('RED: should re-parent children to root when deleteChildren=false on root phase', async () => {
// Create parent (root) -> child hierarchy
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: 'Root parent phase',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: 'Child phase',
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Delete parent WITHOUT deleting children
const result = await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: false,
});
expect(result.deletedPhaseIds).toHaveLength(1);
expect(result.deletedPhaseIds).toContain(parent.phaseId);
// Child should be re-parented to root (parentId = null)
const { phase: childPhase } = await service.getPhase({
planId,
phaseId: child.phaseId,
});
expect(childPhase.parentId).toBeNull();
expect(childPhase.depth).toBe(0);
// Path should be recalculated as root level
expect(childPhase.path).not.toContain('.');
});
it('RED: should re-parent children to grandparent when deleteChildren=false on middle phase', async () => {
// Create grandparent -> parent -> child hierarchy
const grandparent = await service.addPhase({
planId,
phase: {
title: 'Grandparent',
description: 'Top level',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: 'Middle level',
objectives: [],
deliverables: [],
successCriteria: [],
parentId: grandparent.phaseId,
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: 'Bottom level',
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Delete parent (middle) WITHOUT deleting children
const result = await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: false,
});
expect(result.deletedPhaseIds).toHaveLength(1);
expect(result.deletedPhaseIds).toContain(parent.phaseId);
// Child should be re-parented to grandparent
const { phase: childPhase } = await service.getPhase({
planId,
phaseId: child.phaseId,
});
expect(childPhase.parentId).toBe(grandparent.phaseId);
expect(childPhase.depth).toBe(1);
// Path should be under grandparent
expect(childPhase.path).toMatch(/^1\.\d+$/);
});
it('RED: should update paths of all descendants recursively', async () => {
// Create: root -> parent -> child -> grandchild
const root = await service.addPhase({
planId,
phase: {
title: 'Root',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: root.phaseId,
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const grandchild = await service.addPhase({
planId,
phase: {
title: 'Grandchild',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: child.phaseId,
},
});
// Delete parent, keeping children (child -> grandchild should be re-parented to root)
await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: false,
});
// Get updated phases
const { phase: childPhase } = await service.getPhase({ planId, phaseId: child.phaseId });
const { phase: grandchildPhase } = await service.getPhase({ planId, phaseId: grandchild.phaseId });
// Child should be under root
expect(childPhase.parentId).toBe(root.phaseId);
expect(childPhase.depth).toBe(1);
// Grandchild should be under child with updated path
expect(grandchildPhase.parentId).toBe(child.phaseId);
expect(grandchildPhase.depth).toBe(2);
// Grandchild path should reflect new hierarchy
expect(grandchildPhase.path.split('.').length).toBe(3);
});
it('RED: should handle multiple children when deleteChildren=false', async () => {
// Create parent with 3 children
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const child1 = await service.addPhase({
planId,
phase: {
title: 'Child 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const child2 = await service.addPhase({
planId,
phase: {
title: 'Child 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const child3 = await service.addPhase({
planId,
phase: {
title: 'Child 3',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Delete parent WITHOUT children
await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: false,
});
// All children should be re-parented to root
const { phase: c1 } = await service.getPhase({ planId, phaseId: child1.phaseId });
const { phase: c2 } = await service.getPhase({ planId, phaseId: child2.phaseId });
const { phase: c3 } = await service.getPhase({ planId, phaseId: child3.phaseId });
expect(c1.parentId).toBeNull();
expect(c2.parentId).toBeNull();
expect(c3.parentId).toBeNull();
expect(c1.depth).toBe(0);
expect(c2.depth).toBe(0);
expect(c3.depth).toBe(0);
});
it('RED: should return re-parented phase IDs in result', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const result = await service.deletePhase({
planId,
phaseId: parent.phaseId,
deleteChildren: false,
});
// Result should indicate re-parented phases
expect(result.deletedPhaseIds).toHaveLength(1);
// Optionally check for reparentedPhaseIds if we add it to result
expect((result as { reparentedPhaseIds?: string[] }).reparentedPhaseIds).toContain(child.phaseId);
});
});
});
describe('move_phase', () => {
it('should move phase to new parent', async () => {
const p1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const p2 = await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
// Move Phase 2 under Phase 1
await service.movePhase({
planId,
phaseId: p2.phaseId,
newParentId: p1.phaseId,
newOrder: 1,
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: p2.phaseId });
expect(phase.parentId).toBe(p1.phaseId);
expect(phase.depth).toBe(1);
expect(phase.path).toBe('1.1');
});
});
describe('phase with implementation details', () => {
it('should add phase with implementationNotes', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Implementation Phase',
description: 'Phase with notes',
objectives: ['Build feature'],
deliverables: ['Code'],
successCriteria: ['Tests pass'],
implementationNotes: '## TDD Steps\n1. Write failing test\n2. Implement\n3. Refactor',
},
});
// Verify via getPhase
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId, fields: ['*'] });
expect(phase.implementationNotes).toBe(
'## TDD Steps\n1. Write failing test\n2. Implement\n3. Refactor'
);
});
it('should update phase with implementationNotes', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhase({
planId,
phaseId: added.phaseId,
updates: {
implementationNotes: '## Updated Notes\n- Step 1\n- Step 2',
},
});
// Verify via getPhase
const { phase } = await service.getPhase({
planId,
phaseId: added.phaseId,
fields: ['*'],
});
expect(phase.implementationNotes).toBe('## Updated Notes\n- Step 1\n- Step 2');
});
});
describe('get_tree summary mode (Phase 1.9)', () => {
it('should return only summary fields by default', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Full description',
objectives: ['Build API', 'Write tests'],
deliverables: ['API code'],
successCriteria: ['Tests pass'],
implementationNotes: 'Some implementation notes',
},
});
const result = await service.getPhaseTree({ planId });
const node = result.tree[0];
// Summary fields must be present
expect(node.phase.id).toBe(added.phaseId);
expect(node.phase.title).toBe('Test Phase');
expect(node.phase.status).toBe('planned');
expect(node.phase.progress).toBe(0);
expect(node.phase.path).toBe('1');
expect(node.hasChildren).toBe(false);
// childCount must be added to summary
expect((node.phase as Record<string, unknown>).childCount).toBe(0);
// These fields should NOT be present in summary mode
expect((node.phase as Record<string, unknown>).objectives).toBeUndefined();
expect((node.phase as Record<string, unknown>).deliverables).toBeUndefined();
expect((node.phase as Record<string, unknown>).description).toBeUndefined();
expect((node.phase as Record<string, unknown>).successCriteria).toBeUndefined();
expect((node.phase as Record<string, unknown>).implementationNotes).toBeUndefined();
expect((node.phase as Record<string, unknown>).schedule).toBeUndefined();
// Metadata IS included in summary mode (use excludeMetadata to remove it)
expect((node.phase as Record<string, unknown>).metadata).toBeDefined();
});
it('should include objectives when requested via fields', async () => {
await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Desc',
objectives: ['Build API', 'Write tests'],
deliverables: ['Code'],
successCriteria: ['Pass'],
},
});
const result = await service.getPhaseTree({
planId,
fields: ['id', 'title', 'status', 'childCount', 'objectives'],
});
const phase = result.tree[0].phase as Record<string, unknown>;
// Requested fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Test');
expect(phase.status).toBe('planned');
expect(phase.childCount).toBe(0);
expect(phase.objectives).toEqual(['Build API', 'Write tests']);
// NOT requested - should not be present
expect(phase.deliverables).toBeUndefined();
expect(phase.description).toBeUndefined();
});
it('should include multiple fields when requested', async () => {
await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Full description',
objectives: ['obj1', 'obj2'],
deliverables: ['del1'],
successCriteria: ['sc1'],
estimatedEffort: { value: 2, unit: 'hours', confidence: 'high' },
},
});
const result = await service.getPhaseTree({
planId,
fields: ['id', 'title', 'objectives', 'deliverables', 'schedule'],
});
const phase = result.tree[0].phase;
// Requested fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Test');
expect(phase.objectives).toEqual(['obj1', 'obj2']);
expect(phase.deliverables).toEqual(['del1']);
expect(phase.schedule).toBeDefined();
expect((phase.schedule as { estimatedEffort?: { value: number } } | undefined)?.estimatedEffort?.value).toBe(2);
// NOT requested - should not be present
expect(phase.successCriteria).toBeUndefined();
expect(phase.description).toBeUndefined();
});
it('should return full phase when fields=["*"]', async () => {
await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Full description',
objectives: ['obj1'],
deliverables: ['del1'],
successCriteria: ['sc1'],
implementationNotes: 'Some notes',
estimatedEffort: { value: 3, unit: 'hours', confidence: 'medium' },
},
});
const result = await service.getPhaseTree({ planId, fields: ['*'] });
const phase = result.tree[0].phase as Record<string, unknown>;
// ALL fields must be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Test');
expect(phase.description).toBe('Full description');
expect(phase.objectives).toEqual(['obj1']);
expect(phase.deliverables).toEqual(['del1']);
expect(phase.successCriteria).toEqual(['sc1']);
expect(phase.implementationNotes).toBe('Some notes');
expect(phase.metadata).toBeDefined();
expect(phase.schedule).toBeDefined();
expect(phase.childCount).toBe(0);
});
// BUG-040: Changed behavior - unknown fields now throw ValidationError
it('should reject unknown fields with ValidationError', async () => {
await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Desc',
objectives: ['obj1'],
deliverables: [],
successCriteria: [],
},
});
// Should throw ValidationError for unknown fields
await expect(async () => {
await service.getPhaseTree({
planId,
fields: ['objectives', 'unknownField123', 'anotherBadField'],
});
}).rejects.toThrow(/Invalid field.*unknownField123.*anotherBadField/i);
});
it('should respect maxDepth=0 to return only root phases', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Add grandchild
await service.addPhase({
planId,
phase: {
title: 'Grandchild',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: child.phaseId,
},
});
const result = await service.getPhaseTree({ planId, maxDepth: 0 });
expect(result.tree).toHaveLength(1);
expect(result.tree[0].phase.title).toBe('Parent');
expect(result.tree[0].children).toEqual([]); // children truncated
expect(result.tree[0].hasChildren).toBe(true); // but flag set
expect((result.tree[0].phase as Record<string, unknown>).childCount).toBe(1); // direct children count
});
it('should respect maxDepth=1 to include one level of children', async () => {
const p1 = await service.addPhase({
planId,
phase: {
title: 'P1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const p11 = await service.addPhase({
planId,
phase: {
title: 'P1.1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: p1.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'P1.1.1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: p11.phaseId,
},
});
const result = await service.getPhaseTree({ planId, maxDepth: 1 });
// Root level
expect(result.tree).toHaveLength(1);
expect(result.tree[0].phase.title).toBe('P1');
expect((result.tree[0].phase as Record<string, unknown>).childCount).toBe(1);
// First level children included
expect(result.tree[0].children).toHaveLength(1);
expect(result.tree[0].children[0].phase.title).toBe('P1.1');
expect((result.tree[0].children[0].phase as Record<string, unknown>).childCount).toBe(1);
// Second level children truncated
expect(result.tree[0].children[0].children).toEqual([]);
expect(result.tree[0].children[0].hasChildren).toBe(true);
});
it('should calculate childCount correctly for direct children only', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const c1 = await service.addPhase({
planId,
phase: {
title: 'Child1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'Child2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'Child3',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Add grandchildren - should NOT count in parent's childCount
await service.addPhase({
planId,
phase: {
title: 'GC1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: c1.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'GC2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: c1.phaseId,
},
});
const result = await service.getPhaseTree({ planId });
expect((result.tree[0].phase as Record<string, unknown>).childCount).toBe(3); // only direct children
expect(result.tree[0].hasChildren).toBe(true);
expect((result.tree[0].children[0].phase as Record<string, unknown>).childCount).toBe(2); // Child1 has 2 grandchildren
expect((result.tree[0].children[1].phase as Record<string, unknown>).childCount).toBe(0); // Child2 has no children
});
it('should combine maxDepth and fields parameters', async () => {
const parent = await service.addPhase({
planId,
phase: {
title: 'Parent',
description: 'Parent desc',
objectives: ['Build parent'],
deliverables: ['Parent code'],
successCriteria: ['Parent works'],
},
});
await service.addPhase({
planId,
phase: {
title: 'Child',
description: 'Child desc',
objectives: ['Test child'],
deliverables: ['Child tests'],
successCriteria: ['Tests pass'],
parentId: parent.phaseId,
},
});
const result = await service.getPhaseTree({
planId,
maxDepth: 0,
fields: ['id', 'title', 'childCount', 'objectives', 'deliverables'],
});
expect(result.tree).toHaveLength(1);
const phase = result.tree[0].phase as Record<string, unknown>;
// Requested fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Parent');
expect(phase.childCount).toBe(1);
expect(phase.objectives).toEqual(['Build parent']);
expect(phase.deliverables).toEqual(['Parent code']);
// NOT requested - should not be present
expect(phase.description).toBeUndefined();
expect(phase.successCriteria).toBeUndefined();
// maxDepth worked
expect(result.tree[0].children).toEqual([]);
expect(result.tree[0].hasChildren).toBe(true);
});
it('should return significantly smaller response in summary mode', async () => {
// Create 5 phases with full data
for (let i = 1; i <= 5; i++) {
await service.addPhase({
planId,
phase: {
title: `Phase ${i.toString()}`,
description:
'A very long description with lots of details that would make the response large.',
objectives: ['Objective 1', 'Objective 2', 'Objective 3'],
deliverables: ['Deliverable 1', 'Deliverable 2'],
successCriteria: ['Criteria 1', 'Criteria 2'],
implementationNotes: 'Detailed implementation notes here.',
estimatedEffort: { value: 8, unit: 'hours', confidence: 'medium' },
},
});
}
const summaryResult = await service.getPhaseTree({ planId });
const fullResult = await service.getPhaseTree({ planId, fields: ['*'] });
const summarySize = JSON.stringify(summaryResult).length;
const fullSize = JSON.stringify(fullResult).length;
// Summary should be smaller than full (includes metadata by default, still excludes heavy fields)
expect(summarySize).toBeLessThan(fullSize);
// Verify meaningful compression ratio (full should be at least 1.5x larger)
expect(fullSize / summarySize).toBeGreaterThan(1.5);
});
});
describe('order/path calculation (Sprint 5 - Bug Fix)', () => {
describe('auto-generated order', () => {
it('should set order=1 for first root phase', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'First',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(1);
expect(phase.path).toBe('1');
});
it('should set order=2 for second root phase', async () => {
await service.addPhase({
planId,
phase: {
title: 'First',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const result = await service.addPhase({
planId,
phase: {
title: 'Second',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(2);
expect(phase.path).toBe('2');
});
it('should calculate order based on max sibling order, not count', async () => {
// Create phases with orders 2, 3, 4 (skipping 1)
await service.addPhase({
planId,
phase: { title: 'P2', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 2 },
});
await service.addPhase({
planId,
phase: { title: 'P3', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 3 },
});
await service.addPhase({
planId,
phase: { title: 'P4', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 4 },
});
// Add new phase without explicit order
const result = await service.addPhase({
planId,
phase: { title: 'New', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// Should be 5, not 4 (siblings.length + 1)
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(5);
expect(phase.path).toBe('5');
});
it('should handle gaps in order sequence', async () => {
// Create phases with orders 1, 5, 10
await service.addPhase({
planId,
phase: { title: 'P1', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 1 },
});
await service.addPhase({
planId,
phase: { title: 'P5', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 5 },
});
await service.addPhase({
planId,
phase: { title: 'P10', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 10 },
});
// Add new phase
const result = await service.addPhase({
planId,
phase: { title: 'New', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// Should be 11, not 4
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(11);
expect(phase.path).toBe('11');
});
it('should set order=1 for first child phase', async () => {
const parent = await service.addPhase({
planId,
phase: { title: 'Parent', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const child = await service.addPhase({
planId,
phase: {
title: 'Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
const { phase } = await service.getPhase({ planId, phaseId: child.phaseId });
expect(phase.order).toBe(1);
expect(phase.path).toBe('1.1');
});
it('should calculate child order based on max sibling order', async () => {
const parent = await service.addPhase({
planId,
phase: { title: 'Parent', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// Create children with orders 2, 5, 8
await service.addPhase({
planId,
phase: {
title: 'C2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
order: 2,
},
});
await service.addPhase({
planId,
phase: {
title: 'C5',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
order: 5,
},
});
await service.addPhase({
planId,
phase: {
title: 'C8',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
order: 8,
},
});
// Add new child without order
const result = await service.addPhase({
planId,
phase: {
title: 'New Child',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
// Should be 9, not 4
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(9);
expect(phase.path).toBe('1.9');
});
});
describe('explicit order', () => {
it('should use explicit order when provided', async () => {
await service.addPhase({
planId,
phase: { title: 'First', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const result = await service.addPhase({
planId,
phase: { title: 'Explicit', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 99 },
});
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.order).toBe(99);
expect(phase.path).toBe('99');
});
it('should throw error when explicit order conflicts with existing', async () => {
await service.addPhase({
planId,
phase: { title: 'First', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 5 },
});
await expect(
service.addPhase({
planId,
phase: { title: 'Conflict', description: undefined, objectives: [], deliverables: [], successCriteria: [], order: 5 },
})
).rejects.toThrow(/order.*already exists|duplicate.*order/i);
});
it('should allow same order for different parents', async () => {
const parent1 = await service.addPhase({
planId,
phase: { title: 'P1', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const parent2 = await service.addPhase({
planId,
phase: { title: 'P2', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const child1 = await service.addPhase({
planId,
phase: {
title: 'C1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent1.phaseId,
order: 1,
},
});
const child2 = await service.addPhase({
planId,
phase: {
title: 'C2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent2.phaseId,
order: 1,
},
});
const { phase: c1Phase } = await service.getPhase({ planId, phaseId: child1.phaseId });
const { phase: c2Phase } = await service.getPhase({ planId, phaseId: child2.phaseId });
expect(c1Phase.order).toBe(1);
expect(c2Phase.order).toBe(1);
expect(c1Phase.path).toBe('1.1');
expect(c2Phase.path).toBe('2.1');
});
});
describe('path uniqueness', () => {
it('should generate unique paths for all siblings', async () => {
const phaseIds = [];
for (let i = 0; i < 15; i++) {
const result = await service.addPhase({
planId,
phase: { title: `Phase ${String(i)}`, description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
phaseIds.push(result.phaseId);
}
const paths = [];
for (const phaseId of phaseIds) {
const { phase } = await service.getPhase({ planId, phaseId });
paths.push(phase.path);
}
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(15);
}, 15000); // Increase timeout for 15 phases under parallel test load
it('should maintain path consistency after delete and add', async () => {
await service.addPhase({
planId,
phase: { title: 'P1', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const p2 = await service.addPhase({
planId,
phase: { title: 'P2', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.addPhase({
planId,
phase: { title: 'P3', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// Delete middle phase
await service.deletePhase({ planId, phaseId: p2.phaseId });
// Add new phase
const p4 = await service.addPhase({
planId,
phase: { title: 'P4', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// Should get order 4, not 3 (even though only 2 siblings now)
const { phase } = await service.getPhase({ planId, phaseId: p4.phaseId });
expect(phase.order).toBe(4);
expect(phase.path).toBe('4');
});
});
});
describe('minimal return values (Sprint 6)', () => {
describe('addPhase should return only phaseId', () => {
it('should not include full phase object in result', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
expect(result.phaseId).toBeDefined();
expect(result).not.toHaveProperty('phase');
});
});
describe('updatePhase should return only success and phaseId', () => {
it('should not include full phase object in result', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Test',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const result = await service.updatePhase({
planId,
phaseId: added.phaseId,
updates: { title: 'Updated' },
});
expect(result.success).toBe(true);
expect(result).not.toHaveProperty('phase');
});
});
describe('BUG #18: Title validation in updatePhase (TDD - RED phase)', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Original Title',
},
});
phaseId = result.phaseId;
});
it('RED: should reject empty title', async () => {
await expect(service.updatePhase({
planId,
phaseId,
updates: { title: '' },
})).rejects.toThrow('title must be a non-empty string');
});
it('RED: should reject whitespace-only title', async () => {
await expect(service.updatePhase({
planId,
phaseId,
updates: { title: ' ' },
})).rejects.toThrow('title must be a non-empty string');
});
it('GREEN: should allow valid title update', async () => {
const result = await service.updatePhase({
planId,
phaseId,
updates: { title: 'New Valid Title' },
});
expect(result.success).toBe(true);
const updated = await service.getPhase({
planId,
phaseId,
});
expect(updated.phase.title).toBe('New Valid Title');
});
});
describe('BUGS #16, #17, #19: Status validation in phase.update (TDD)', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Phase for Status Test',
},
});
phaseId = result.phaseId;
});
it('RED: should reject invalid status in updatePhase', async () => {
await expect(service.updatePhase({
planId,
phaseId,
updates: { status: 'invalid_status' as unknown as 'planned' },
})).rejects.toThrow('status must be one of: planned, in_progress, completed, blocked, skipped');
});
it('GREEN: should allow valid status in updatePhase', async () => {
const result = await service.updatePhase({
planId,
phaseId,
updates: { status: 'in_progress' },
});
expect(result.success).toBe(true);
const updated = await service.getPhase({ planId, phaseId });
expect(updated.phase.status).toBe('in_progress');
});
});
describe('BUGS #16, #17, #19: Status validation in updatePhaseStatus (TDD)', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Phase for Status Test',
},
});
phaseId = result.phaseId;
});
it('RED: should reject invalid status in updatePhaseStatus', async () => {
await expect(service.updatePhaseStatus({
planId,
phaseId,
status: 'super_status' as unknown as 'planned',
})).rejects.toThrow('status must be one of: planned, in_progress, completed, blocked, skipped');
});
it('GREEN: should allow valid status in updatePhaseStatus', async () => {
const result = await service.updatePhaseStatus({
planId,
phaseId,
status: 'completed',
});
expect(result.success).toBe(true);
const updated = await service.getPhase({ planId, phaseId });
expect(updated.phase.status).toBe('completed');
});
it('GREEN: should accept all valid status values', async () => {
const validStatuses: ('planned' | 'in_progress' | 'completed' | 'blocked' | 'skipped')[] =
['planned', 'in_progress', 'completed', 'blocked', 'skipped'];
for (const status of validStatuses) {
const { phaseId: testPhaseId } = await service.addPhase({
planId,
phase: { title: `Test ${status}` },
});
// blocked requires notes
if (status === 'blocked') {
await service.updatePhaseStatus({
planId,
phaseId: testPhaseId,
status,
notes: 'Blocked reason',
});
} else {
await service.updatePhaseStatus({
planId,
phaseId: testPhaseId,
status,
});
}
const updated = await service.getPhase({ planId, phaseId: testPhaseId });
expect(updated.phase.status).toBe(status);
}
});
});
// Sprint 2: Numeric Validation (Bugs #4, #8, #9, #10)
// RED phase - these tests should FAIL initially until validation is implemented
describe('Sprint 2: Numeric validation (effortEstimate.value and progress)', () => {
describe('effortEstimate.value validation', () => {
it('RED: should reject negative effortEstimate.value in addPhase', async () => {
await expect(service.addPhase({
planId,
phase: {
title: 'Phase with negative effort',
estimatedEffort: { value: -5, unit: 'hours', confidence: 'medium' },
},
})).rejects.toThrow("Invalid estimatedEffort: 'value' must be >= 0");
});
it('RED: should reject negative effortEstimate.value via schedule.estimatedEffort', async () => {
await expect(service.addPhase({
planId,
phase: {
title: 'Phase with negative schedule effort',
schedule: {
estimatedEffort: { value: -1, unit: 'days', confidence: 'high' },
},
},
})).rejects.toThrow("Invalid estimatedEffort: 'value' must be >= 0");
});
it('GREEN: should accept zero effortEstimate.value', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Phase with zero effort',
estimatedEffort: { value: 0, unit: 'hours', confidence: 'low' },
},
});
expect(result.phaseId).toBeDefined();
});
it('GREEN: should accept positive effortEstimate.value', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Phase with positive effort',
estimatedEffort: { value: 10, unit: 'hours', confidence: 'high' },
},
});
expect(result.phaseId).toBeDefined();
});
});
describe('progress validation in updatePhase', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: { title: 'Phase for Progress Test' },
});
phaseId = result.phaseId;
});
it('RED: should reject progress > 100 in updatePhase', async () => {
await expect(service.updatePhase({
planId,
phaseId,
updates: { progress: 150 },
})).rejects.toThrow('progress must be between 0 and 100');
});
it('RED: should reject progress < 0 in updatePhase', async () => {
await expect(service.updatePhase({
planId,
phaseId,
updates: { progress: -10 },
})).rejects.toThrow('progress must be between 0 and 100');
});
it('GREEN: should accept progress = 0', async () => {
const result = await service.updatePhase({
planId,
phaseId,
updates: { progress: 0 },
});
expect(result.success).toBe(true);
});
it('GREEN: should accept progress = 100', async () => {
const result = await service.updatePhase({
planId,
phaseId,
updates: { progress: 100 },
});
expect(result.success).toBe(true);
});
it('GREEN: should accept progress = 50 (mid-range)', async () => {
const result = await service.updatePhase({
planId,
phaseId,
updates: { progress: 50 },
});
expect(result.success).toBe(true);
const updated = await service.getPhase({ planId, phaseId });
expect(updated.phase.progress).toBe(50);
});
});
describe('progress validation in updatePhaseStatus', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: { title: 'Phase for Status Progress Test' },
});
phaseId = result.phaseId;
});
it('RED: should reject progress > 100 in updatePhaseStatus', async () => {
await expect(service.updatePhaseStatus({
planId,
phaseId,
status: 'in_progress',
progress: 200,
})).rejects.toThrow('progress must be between 0 and 100');
});
it('RED: should reject progress < 0 in updatePhaseStatus', async () => {
await expect(service.updatePhaseStatus({
planId,
phaseId,
status: 'in_progress',
progress: -5,
})).rejects.toThrow('progress must be between 0 and 100');
});
it('GREEN: should accept valid progress in updatePhaseStatus', async () => {
const result = await service.updatePhaseStatus({
planId,
phaseId,
status: 'in_progress',
progress: 75,
});
expect(result.success).toBe(true);
const updated = await service.getPhase({ planId, phaseId });
expect(updated.phase.progress).toBe(75);
});
});
});
describe('updatePhaseStatus should return only success and phaseId', () => {
it('should not include full phase object in result', async () => {
const added = await service.addPhase({
planId,
phase: {
title: 'Test',
description: 'Test',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const result = await service.updatePhaseStatus({
planId,
phaseId: added.phaseId,
status: 'in_progress',
});
expect(result.success).toBe(true);
expect(result).not.toHaveProperty('phase');
});
});
describe('movePhase should return only success and IDs', () => {
it('should not include full phase objects in result', async () => {
const p1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
const result = await service.movePhase({
planId,
phaseId: p1.phaseId,
newOrder: 2,
});
expect(result.success).toBe(true);
expect(result).not.toHaveProperty('phase');
expect(result).not.toHaveProperty('affectedPhases');
});
});
});
// ============================================================
// CYCLE 1-9: complete_and_advance (Task 1.6)
// ============================================================
describe('completeAndAdvance', () => {
// CYCLE 2: Basic Complete Functionality
describe('Basic Completion', () => {
it('should mark current phase as completed with progress 100', async () => {
const p1 = await service.addPhase({
planId,
phase: {
title: 'Task 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
// Minimal return: only IDs
expect(result.success).toBe(true);
expect(result.completedPhaseId).toBe(p1.phaseId);
expect(result.nextPhaseId).toBeNull(); // no siblings
// Verify via get
const completed = await service.getPhase({ planId, phaseId: p1.phaseId });
expect(completed.phase.status).toBe('completed');
expect(completed.phase.progress).toBe(100);
expect(completed.phase.completedAt).toBeDefined();
});
it('should set completedAt timestamp on completed phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Task', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const before = new Date();
await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
const after = new Date();
const completed = await service.getPhase({ planId, phaseId: p1.phaseId });
if (completed.phase.completedAt === undefined) throw new Error('CompletedAt should be defined');
const timestamp = new Date(completed.phase.completedAt);
expect(timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should save actualEffort in completed phase schedule', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Task', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
await service.completeAndAdvance({
planId,
phaseId: p1.phaseId,
actualEffort: 3.5,
});
const completed = await service.getPhase({
planId,
phaseId: p1.phaseId,
fields: ['*'],
});
expect(completed.phase.schedule.actualEffort).toBe(3.5);
});
it('should add annotation with notes to completed phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Task', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
await service.completeAndAdvance({
planId,
phaseId: p1.phaseId,
notes: 'All tests passed',
});
const completed = await service.getPhase({
planId,
phaseId: p1.phaseId,
fields: ['*'],
});
expect(completed.phase.metadata.annotations).toContainEqual(
expect.objectContaining({
text: 'All tests passed',
author: 'claude-code',
})
);
});
it('should increment version of completed phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Task', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const beforeVersion = (
await service.getPhase({ planId, phaseId: p1.phaseId, fields: ['*'] })
).phase.version;
await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
const completed = await service.getPhase({
planId,
phaseId: p1.phaseId,
fields: ['*'],
});
expect(completed.phase.version).toBe(beforeVersion + 1);
});
});
// CYCLE 3: Status Validation
describe('Status Validation', () => {
it('should throw error when phase already completed', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Done', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'completed' });
await expect(service.completeAndAdvance({ planId, phaseId: p1.phaseId })).rejects.toThrow(
/already completed/i
);
});
it('should throw error when trying to complete skipped phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Skipped', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'skipped', notes: 'Not needed' });
await expect(service.completeAndAdvance({ planId, phaseId: p1.phaseId })).rejects.toThrow(
/cannot complete skipped/i
);
});
it('should allow completing phase that is in_progress', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Active', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
expect(result.success).toBe(true);
const completed = await service.getPhase({ planId, phaseId: p1.phaseId });
expect(completed.phase.status).toBe('completed');
});
it('should allow completing phase that is still planned', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Planned', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
// status is 'planned' by default
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
expect(result.success).toBe(true);
const completed = await service.getPhase({ planId, phaseId: p1.phaseId });
expect(completed.phase.status).toBe('completed');
});
it('should throw error when trying to complete blocked phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Blocked', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'blocked', notes: 'Dependency issue' });
await expect(service.completeAndAdvance({ planId, phaseId: p1.phaseId })).rejects.toThrow(
/cannot complete blocked/i
);
});
});
// CYCLE 4-6: Find Next Phase Logic
describe('Find Next Phase', () => {
it('should find and start next planned sibling phase', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Phase 1', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const p2 = await service.addPhase({
planId,
phase: { title: 'Phase 2', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
expect(result.nextPhaseId).toBe(p2.phaseId);
// Verify next phase was started
const nextPhase = await service.getPhase({ planId, phaseId: p2.phaseId });
expect(nextPhase.phase.status).toBe('in_progress');
expect(nextPhase.phase.startedAt).toBeDefined();
});
it('should advance to first planned child when current phase has children', async () => {
const parent = await service.addPhase({
planId,
phase: { title: 'Parent', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const child1 = await service.addPhase({
planId,
phase: {
title: 'Child 1',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
await service.addPhase({
planId,
phase: {
title: 'Child 2',
description: undefined,
objectives: [],
deliverables: [],
successCriteria: [],
parentId: parent.phaseId,
},
});
await service.updatePhaseStatus({ planId, phaseId: parent.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: parent.phaseId });
expect(result.nextPhaseId).toBe(child1.phaseId);
const nextPhase = await service.getPhase({ planId, phaseId: child1.phaseId });
expect(nextPhase.phase.status).toBe('in_progress');
});
it('should skip blocked phases and find next planned', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'P1', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const p2 = await service.addPhase({
planId,
phase: { title: 'P2', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
const p3 = await service.addPhase({
planId,
phase: { title: 'P3', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p2.phaseId, status: 'blocked', notes: 'Issue' });
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
expect(result.nextPhaseId).toBe(p3.phaseId);
});
it('should return null when no more planned phases', async () => {
const p1 = await service.addPhase({
planId,
phase: { title: 'Only Phase', description: undefined, objectives: [], deliverables: [], successCriteria: [] },
});
await service.updatePhaseStatus({ planId, phaseId: p1.phaseId, status: 'in_progress' });
const result = await service.completeAndAdvance({ planId, phaseId: p1.phaseId });
expect(result.nextPhaseId).toBeNull();
});
});
});
describe('fields parameter support', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Complete Phase',
description: 'Full description',
objectives: ['Obj 1', 'Obj 2'],
deliverables: ['Del 1', 'Del 2'],
successCriteria: ['SC 1', 'SC 2'],
implementationNotes: 'Important notes',
priority: 'high',
},
});
phaseId = result.phaseId;
});
describe('getPhase with fields', () => {
it('should return only minimal fields when fields=["id","title"]', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['id', 'title'],
});
const phase = result.phase as unknown as Record<string, unknown>;
expect(phase.id).toBe(phaseId);
expect(phase.title).toBe('Complete Phase');
expect(phase.description).toBeUndefined();
expect(phase.objectives).toBeUndefined();
});
it('should return summary fields by default WITHOUT heavy fields (Lazy-Load)', async () => {
const result = await service.getPhase({
planId,
phaseId,
});
const phase = result.phase;
// GET operations return summary by default (without heavy fields like objectives, etc)
expect(phase.id).toBeDefined();
expect(phase.title).toBeDefined();
expect(phase.status).toBeDefined();
expect(phase.progress).toBeDefined();
expect(phase.path).toBeDefined();
// Lazy-Load: heavy fields NOT included (use fields=['*'] to get them)
expect(phase.objectives).toBeUndefined();
expect(phase.implementationNotes).toBeUndefined();
});
it('should return all fields when fields=["*"]', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['*'],
});
const phase = result.phase;
expect(phase.title).toBe('Complete Phase');
expect(phase.objectives).toEqual(['Obj 1', 'Obj 2']);
expect(phase.implementationNotes).toBe('Important notes');
});
});
});
describe('excludeMetadata and excludeComputed parameters (Sprint 2)', () => {
let phaseId: string;
beforeEach(async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase with Metadata',
description: 'Testing metadata and computed exclusion',
objectives: ['Test objective'],
deliverables: ['Test deliverable'],
successCriteria: ['Test criterion'],
},
});
phaseId = result.phaseId;
});
describe('getPhase with excludeMetadata', () => {
it('should exclude metadata fields when excludeMetadata=true', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['*'],
excludeMetadata: true,
});
const phase = result.phase as unknown as Record<string, unknown>;
// Business fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Test Phase with Metadata');
expect(phase.description).toBe('Testing metadata and computed exclusion');
// Metadata fields should NOT be present
expect(phase.createdAt).toBeUndefined();
expect(phase.updatedAt).toBeUndefined();
expect(phase.version).toBeUndefined();
expect(phase.metadata).toBeUndefined();
});
it('should include metadata fields by default', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['*'],
});
const phase = result.phase;
// Metadata fields should be present by default
expect(phase.createdAt).toBeDefined();
expect(phase.updatedAt).toBeDefined();
expect(phase.version).toBeDefined();
expect(phase.metadata).toBeDefined();
});
});
describe('getPhase with excludeComputed', () => {
it('should exclude computed fields when excludeComputed=true', async () => {
const result = await service.getPhase({
planId,
phaseId,
excludeComputed: true,
});
const phase = result.phase as unknown as Record<string, unknown>;
// Business fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBe('Test Phase with Metadata');
expect(phase.status).toBeDefined();
// Computed fields should NOT be present
expect(phase.depth).toBeUndefined();
expect(phase.path).toBeUndefined();
// Note: childCount is not available in getPhase, only in get_tree
});
it('should include computed fields by default', async () => {
const result = await service.getPhase({
planId,
phaseId,
});
const phase = result.phase;
// Computed fields should be present by default
expect(phase.depth).toBeDefined();
expect(phase.path).toBeDefined();
});
});
describe('getPhase with both excludeMetadata and excludeComputed', () => {
it('should exclude both metadata and computed fields', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['*'],
excludeMetadata: true,
excludeComputed: true,
});
const phase = result.phase as unknown as Record<string, unknown>;
// Business fields present
expect(phase.id).toBeDefined();
expect(phase.title).toBeDefined();
expect(phase.objectives).toBeDefined();
// Metadata fields excluded
expect(phase.createdAt).toBeUndefined();
expect(phase.version).toBeUndefined();
// Computed fields excluded
expect(phase.depth).toBeUndefined();
expect(phase.path).toBeUndefined();
});
it('should work together with fields parameter', async () => {
const result = await service.getPhase({
planId,
phaseId,
fields: ['id', 'title', 'description', 'path', 'version'],
excludeMetadata: true,
excludeComputed: true,
});
const phase = result.phase as unknown as Record<string, unknown>;
// Requested non-metadata/non-computed fields should be present
expect(phase.id).toBeDefined();
expect(phase.title).toBeDefined();
expect(phase.description).toBeDefined();
// Metadata and computed fields excluded even if requested
expect(phase.version).toBeUndefined();
expect(phase.path).toBeUndefined();
});
});
describe('getPhaseTree with excludeComputed', () => {
beforeEach(async () => {
// Add child phase
await service.addPhase({
planId,
phase: {
title: 'Child Phase',
description: 'Child',
parentId: phaseId,
objectives: ['Child objective'],
deliverables: ['Child deliverable'],
successCriteria: ['Child criterion'],
},
});
});
it('should exclude computed fields from tree when excludeComputed=true', async () => {
const result = await service.getPhaseTree({
planId,
excludeComputed: true,
});
expect(result.tree.length).toBeGreaterThan(0);
const phaseNode = result.tree[0].phase as unknown as Record<string, unknown>;
// Business fields present
expect(phaseNode.id).toBeDefined();
expect(phaseNode.title).toBeDefined();
// Computed fields excluded
expect(phaseNode.depth).toBeUndefined();
expect(phaseNode.path).toBeUndefined();
expect(phaseNode.childCount).toBeUndefined();
});
it('should include computed fields in tree by default', async () => {
const result = await service.getPhaseTree({
planId,
});
const phaseNode = result.tree[0].phase;
// Computed fields should be present
expect(phaseNode.path).toBeDefined();
expect((phaseNode as unknown as Record<string, unknown>).childCount).toBeDefined();
});
});
});
describe('get_many operation (Sprint 4: Batch Read)', () => {
it('should get multiple phases by IDs', async () => {
const phase1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Obj 1'],
deliverables: ['Del 1'],
successCriteria: ['Criteria 1'],
},
});
const phase2 = await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: 'Second phase',
objectives: ['Obj 2'],
deliverables: ['Del 2'],
successCriteria: ['Criteria 2'],
},
});
const phase3 = await service.addPhase({
planId,
phase: {
title: 'Phase 3',
description: 'Third phase',
objectives: ['Obj 3'],
deliverables: ['Del 3'],
successCriteria: ['Criteria 3'],
},
});
const result = await service.getPhases({
planId,
phaseIds: [phase1.phaseId, phase2.phaseId, phase3.phaseId],
});
expect(result.phases).toHaveLength(3);
expect(result.phases[0].title).toBe('Phase 1');
expect(result.phases[1].title).toBe('Phase 2');
expect(result.phases[2].title).toBe('Phase 3');
});
it('should support fields parameter in get_many', async () => {
const phase1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Obj 1'],
deliverables: ['Del 1'],
successCriteria: ['Criteria 1'],
},
});
const phase2 = await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: 'Second phase',
objectives: ['Obj 2'],
deliverables: ['Del 2'],
successCriteria: ['Criteria 2'],
},
});
const result = await service.getPhases({
planId,
phaseIds: [phase1.phaseId, phase2.phaseId],
fields: ['id', 'title'],
});
expect(result.phases).toHaveLength(2);
expect(result.phases[0].id).toBeDefined();
expect(result.phases[0].title).toBeDefined();
expect((result.phases[0] as unknown as Record<string, unknown>).description).toBeUndefined();
expect((result.phases[0] as unknown as Record<string, unknown>).objectives).toBeUndefined();
});
it('should handle partial success when some IDs not found', async () => {
const phase1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Obj 1'],
deliverables: ['Del 1'],
successCriteria: ['Criteria 1'],
},
});
const result = await service.getPhases({
planId,
phaseIds: [phase1.phaseId, 'non-existent-id', 'another-fake-id'],
});
expect(result.phases).toHaveLength(1);
expect(result.phases[0].title).toBe('Phase 1');
expect(result.notFound).toEqual(['non-existent-id', 'another-fake-id']);
});
it('should enforce max limit of 100 IDs', async () => {
const manyIds = Array.from({ length: 101 }, (_, i) => `id-${i.toString()}`);
await expect(
service.getPhases({
planId,
phaseIds: manyIds,
})
).rejects.toThrow('Cannot fetch more than 100 phases at once');
});
it('should return empty array when no IDs provided', async () => {
const result = await service.getPhases({
planId,
phaseIds: [],
});
expect(result.phases).toEqual([]);
expect(result.notFound).toEqual([]);
});
it('should support excludeMetadata in get_many', async () => {
const phase1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Obj 1'],
deliverables: ['Del 1'],
successCriteria: ['Criteria 1'],
},
});
const result = await service.getPhases({
planId,
phaseIds: [phase1.phaseId],
excludeMetadata: true,
});
expect(result.phases).toHaveLength(1);
expect((result.phases[0] as unknown as Record<string, unknown>).createdAt).toBeUndefined();
expect((result.phases[0] as unknown as Record<string, unknown>).metadata).toBeUndefined();
});
});
describe('Sprint 5: Array Field Operations', () => {
it('should append item to objectives array', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: ['Objective 1', 'Objective 2'],
deliverables: [],
successCriteria: [],
},
});
await service.arrayAppend({
planId,
phaseId,
field: 'objectives',
value: 'Objective 3',
});
const result = await service.getPhase({ planId, phaseId, fields: ['*'] });
expect(result.phase.objectives).toEqual(['Objective 1', 'Objective 2', 'Objective 3']);
});
it('should prepend item to deliverables array', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: [],
deliverables: ['Item 2', 'Item 3'],
successCriteria: [],
},
});
await service.arrayPrepend({
planId,
phaseId,
field: 'deliverables',
value: 'Item 1',
});
const result = await service.getPhase({ planId, phaseId, fields: ['*'] });
expect(result.phase.deliverables).toEqual(['Item 1', 'Item 2', 'Item 3']);
});
it('should insert item at specific index in successCriteria array', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: [],
deliverables: [],
successCriteria: ['Criteria 1', 'Criteria 3'],
},
});
await service.arrayInsertAt({
planId,
phaseId,
field: 'successCriteria',
index: 1,
value: 'Criteria 2',
});
const result = await service.getPhase({ planId, phaseId, fields: ['*'] });
expect(result.phase.successCriteria).toEqual(['Criteria 1', 'Criteria 2', 'Criteria 3']);
});
it('should update item at specific index', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: ['Old objective', 'Keep this'],
deliverables: [],
successCriteria: [],
},
});
await service.arrayUpdateAt({
planId,
phaseId,
field: 'objectives',
index: 0,
value: 'New objective',
});
const result = await service.getPhase({ planId, phaseId, fields: ['*'] });
expect(result.phase.objectives).toEqual(['New objective', 'Keep this']);
});
it('should remove item at specific index', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: ['Item 1', 'Item 2', 'Item 3'],
deliverables: [],
successCriteria: [],
},
});
await service.arrayRemoveAt({
planId,
phaseId,
field: 'objectives',
index: 1,
});
const result = await service.getPhase({ planId, phaseId, fields: ['*'] });
expect(result.phase.objectives).toEqual(['Item 1', 'Item 3']);
});
it('should throw error when index is out of bounds for insert_at', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: ['Item 1'],
deliverables: [],
successCriteria: [],
},
});
await expect(
service.arrayInsertAt({
planId,
phaseId,
field: 'objectives',
index: 10,
value: 'New item',
})
).rejects.toThrow('Index 10 is out of bounds');
});
it('should throw error when field is not an array field', async () => {
const { phaseId } = await service.addPhase({
planId,
phase: {
title: 'Test Phase',
description: 'Test',
objectives: [],
deliverables: [],
successCriteria: [],
},
});
await expect(
service.arrayAppend({
planId,
phaseId,
field: 'title' as unknown as 'objectives',
value: 'Invalid',
})
).rejects.toThrow('Field title is not a valid array field');
});
});
describe('bulk_update (Sprint 9 - RED Phase)', () => {
let phase1Id: string;
let phase2Id: string;
let phase3Id: string;
beforeEach(async () => {
// Create test phases
const p1 = await service.addPhase({
planId,
phase: {
title: 'Phase 1',
description: 'First phase',
objectives: ['Obj1'],
deliverables: ['Del1'],
successCriteria: ['SC1'],
},
});
phase1Id = p1.phaseId;
const p2 = await service.addPhase({
planId,
phase: {
title: 'Phase 2',
description: 'Second phase',
objectives: ['Obj2'],
deliverables: ['Del2'],
successCriteria: ['SC2'],
},
});
phase2Id = p2.phaseId;
const p3 = await service.addPhase({
planId,
phase: {
title: 'Phase 3',
description: 'Third phase',
objectives: ['Obj3'],
deliverables: ['Del3'],
successCriteria: ['SC3'],
},
});
phase3Id = p3.phaseId;
});
it('RED 9.6: should update multiple phases in one call', async () => {
const result = await service.bulkUpdatePhases({
planId,
updates: [
{ phaseId: phase1Id, updates: { status: 'in_progress' } },
{ phaseId: phase2Id, updates: { progress: 50 } },
{ phaseId: phase3Id, updates: { status: 'completed', progress: 100 } },
],
});
expect(result.updated).toBe(3);
expect(result.failed).toBe(0);
expect(result.results).toHaveLength(3);
const updated1 = await service.getPhase({ planId, phaseId: phase1Id });
expect(updated1.phase.status).toBe('in_progress');
const updated2 = await service.getPhase({ planId, phaseId: phase2Id });
expect(updated2.phase.progress).toBe(50);
const updated3 = await service.getPhase({ planId, phaseId: phase3Id });
expect(updated3.phase.status).toBe('completed');
expect(updated3.phase.progress).toBe(100);
});
it('RED 9.7: should handle partial failures in non-atomic mode', async () => {
const result = await service.bulkUpdatePhases({
planId,
updates: [
{ phaseId: phase1Id, updates: { status: 'completed' } },
{ phaseId: 'invalid-id', updates: { status: 'in_progress' } },
{ phaseId: phase3Id, updates: { progress: 75 } },
],
atomic: false,
});
expect(result.updated).toBe(2);
expect(result.failed).toBe(1);
expect(result.results).toHaveLength(3);
expect(result.results[0].success).toBe(true);
expect(result.results[1].success).toBe(false);
expect(result.results[2].success).toBe(true);
// Verify successful updates were applied
const check1 = await service.getPhase({ planId, phaseId: phase1Id });
expect(check1.phase.status).toBe('completed');
const check3 = await service.getPhase({ planId, phaseId: phase3Id });
expect(check3.phase.progress).toBe(75);
});
it('RED 9.8: should rollback all changes in atomic mode on error', async () => {
await expect(
service.bulkUpdatePhases({
planId,
updates: [
{ phaseId: phase1Id, updates: { status: 'completed' } },
{ phaseId: 'invalid-id', updates: { status: 'in_progress' } },
],
atomic: true,
})
).rejects.toThrow();
// Verify no changes were applied
const check1 = await service.getPhase({ planId, phaseId: phase1Id });
expect(check1.phase.status).toBe('planned'); // unchanged
});
});
// Sprint 4: BUG #7 - Status validation in addPhase
describe('BUG #7: Status validation in addPhase (TDD - Sprint 4)', () => {
it('RED: should reject invalid status value', async () => {
await expect(
service.addPhase({
planId,
phase: {
title: 'Test Phase',
status: 'invalid_status' as unknown as 'planned',
},
})
).rejects.toThrow('status must be one of: planned, in_progress, completed, blocked, skipped');
});
it('RED: should accept valid status "in_progress"', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase with in_progress',
status: 'in_progress',
},
});
expect(result.phaseId).toBeDefined();
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.status).toBe('in_progress');
});
it('RED: should accept valid status "completed"', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase with completed',
status: 'completed',
},
});
expect(result.phaseId).toBeDefined();
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.status).toBe('completed');
});
it('RED: should accept valid status "blocked"', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase with blocked',
status: 'blocked',
},
});
expect(result.phaseId).toBeDefined();
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.status).toBe('blocked');
});
it('RED: should accept valid status "skipped"', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase with skipped',
status: 'skipped',
},
});
expect(result.phaseId).toBeDefined();
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.status).toBe('skipped');
});
it('RED: should default to "planned" when status not provided', async () => {
const result = await service.addPhase({
planId,
phase: {
title: 'Test Phase without status',
},
});
expect(result.phaseId).toBeDefined();
const { phase } = await service.getPhase({ planId, phaseId: result.phaseId });
expect(phase.status).toBe('planned');
});
});
});