Skip to main content
Glama
plan-service.test.ts29.3 kB
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 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 type { PlanStatus } from '../../src/domain/entities/types.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; describe('PlanService', () => { let service: PlanService; let repositoryFactory: RepositoryFactory; let lockManager: FileLockManager; let testDir: string; beforeEach(async () => { testDir = path.join(os.tmpdir(), `mcp-plan-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(); service = new PlanService(repositoryFactory); }); afterEach(async () => { await repositoryFactory.dispose(); await lockManager.dispose(); await fs.rm(testDir, { recursive: true, force: true }); }); describe('create_plan', () => { it('should create a new plan', async () => { const result = await service.createPlan({ name: 'Test Plan', description: 'A test plan', }); expect(result.planId).toBeDefined(); // Verify via getPlan const { plan } = await service.getPlan({ planId: result.planId }); expect(plan.manifest.name).toBe('Test Plan'); expect(plan.manifest.status).toBe('active'); }); it('should set author', async () => { const result = await service.createPlan({ name: 'Test Plan', description: 'A test plan', author: 'test-user', }); // Verify via getPlan const { plan } = await service.getPlan({ planId: result.planId }); expect(plan.manifest.author).toBe('test-user'); }); it('should initialize statistics to zero', async () => { const result = await service.createPlan({ name: 'Test Plan', description: 'A test plan', }); // Verify via getPlan const { plan } = await service.getPlan({ planId: result.planId }); expect(plan.manifest.statistics.totalRequirements).toBe(0); expect(plan.manifest.statistics.totalSolutions).toBe(0); expect(plan.manifest.statistics.completionPercentage).toBe(0); }); }); describe('list_plans', () => { it('should return empty list when no plans', async () => { const result = await service.listPlans({}); expect(result.plans).toHaveLength(0); expect(result.total).toBe(0); }); it('should list all plans', async () => { await service.createPlan({ name: 'Plan 1', description: 'First' }); await service.createPlan({ name: 'Plan 2', description: 'Second' }); const result = await service.listPlans({}); expect(result.plans).toHaveLength(2); expect(result.total).toBe(2); }); it('should filter by status', async () => { await service.createPlan({ name: 'Active Plan', description: 'Active' }); const archived = await service.createPlan({ name: 'Archived Plan', description: 'Archived' }); await service.archivePlan({ planId: archived.planId }); const result = await service.listPlans({ status: 'active' }); expect(result.plans).toHaveLength(1); expect(result.plans[0].name).toBe('Active Plan'); }); it('should support pagination', async () => { await service.createPlan({ name: 'Plan 1', description: 'First' }); await service.createPlan({ name: 'Plan 2', description: 'Second' }); await service.createPlan({ name: 'Plan 3', description: 'Third' }); const result = await service.listPlans({ limit: 2, offset: 0 }); expect(result.plans).toHaveLength(2); expect(result.hasMore).toBe(true); }); }); describe('get_plan', () => { it('should get plan by id', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); const result = await service.getPlan({ planId: created.planId }); expect(result.plan.manifest.id).toBe(created.planId); expect(result.plan.manifest.name).toBe('Test Plan'); }); it('should throw if plan not found', async () => { await expect(service.getPlan({ planId: 'non-existent' })) .rejects.toThrow('Plan not found'); }); it('should include entities if requested', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); const result = await service.getPlan({ planId: created.planId, includeEntities: true, }); expect(result.plan.entities).toBeDefined(); expect(result.plan.entities?.requirements).toEqual([]); }); }); describe('update_plan', () => { it('should update plan name', async () => { const created = await service.createPlan({ name: 'Old Name', description: 'Test' }); await service.updatePlan({ planId: created.planId, updates: { name: 'New Name' }, }); // Verify via getPlan const { plan } = await service.getPlan({ planId: created.planId }); expect(plan.manifest.name).toBe('New Name'); }); it('should update plan status', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); await service.updatePlan({ planId: created.planId, updates: { status: 'completed' }, }); // Verify via getPlan const { plan } = await service.getPlan({ planId: created.planId }); expect(plan.manifest.status).toBe('completed'); }); it('should increment version on update', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); await service.updatePlan({ planId: created.planId, updates: { name: 'Updated' }, }); const updated = await service.getPlan({ planId: created.planId }); expect(updated.plan.manifest.version).toBe(2); }); }); describe('archive_plan', () => { it('should archive plan (soft delete)', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); const result = await service.archivePlan({ planId: created.planId }); expect(result.success).toBe(true); const plan = await service.getPlan({ planId: created.planId }); expect(plan.plan.manifest.status).toBe('archived'); }); it('should permanently delete plan if requested', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); await service.archivePlan({ planId: created.planId, permanent: true }); await expect(service.getPlan({ planId: created.planId })) .rejects.toThrow('Plan not found'); }); }); describe('set_active_plan / get_active_plan', () => { it('should set active plan for workspace', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); const result = await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace', }); expect(result.success).toBe(true); expect(result.activePlan.planId).toBe(created.planId); }); it('should get active plan for workspace', async () => { const created = await service.createPlan({ name: 'Test', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace' }); expect(result.activePlan?.planId).toBe(created.planId); }); it('should return null if no active plan', async () => { const result = await service.getActivePlan({ workspacePath: '/no/plan/here' }); expect(result.activePlan).toBeNull(); }); it('should update active plan for same workspace', async () => { const plan1 = await service.createPlan({ name: 'Plan 1', description: 'First' }); const plan2 = await service.createPlan({ name: 'Plan 2', description: 'Second' }); await service.setActivePlan({ planId: plan1.planId, workspacePath: '/test' }); await service.setActivePlan({ planId: plan2.planId, workspacePath: '/test' }); const result = await service.getActivePlan({ workspacePath: '/test' }); expect(result.activePlan?.planId).toBe(plan2.planId); }); }); describe('getActivePlan with usageGuide (Sprint 9)', () => { it('should return usageGuide when explicitly requested with includeGuide=true', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace', }); // Sprint 6: Default changed to false, must explicitly request guide const result = await service.getActivePlan({ workspacePath: '/test/workspace', includeGuide: true }); expect(result.activePlan).toBeDefined(); if (result.activePlan === null) throw new Error('ActivePlan should be defined'); expect(result.activePlan.usageGuide).toBeDefined(); if (result.activePlan.usageGuide === undefined) throw new Error('UsageGuide should be defined'); expect(result.activePlan.usageGuide.quickStart).toContain('phase get_tree'); expect(result.activePlan.usageGuide.commands.overview).toHaveLength(4); expect(result.activePlan.usageGuide.warnings[0]).toContain("NEVER use fields: ['*']"); }); it('should exclude usageGuide when includeGuide=false', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace', includeGuide: false, }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); }); it('should have valid usageGuide structure', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace', }); // Sprint 6: Must explicitly request guide with includeGuide=true const result = await service.getActivePlan({ workspacePath: '/test/workspace', includeGuide: true }); if (result.activePlan === null) throw new Error('ActivePlan should be defined'); if (result.activePlan.usageGuide === undefined) throw new Error('UsageGuide should be defined'); const guide = result.activePlan.usageGuide; expect(guide.quickStart).toBeTruthy(); expect(guide.commands.overview).toBeInstanceOf(Array); expect(guide.commands.detailed).toBeInstanceOf(Array); expect(guide.formattingGuide).toContain('Tree View'); expect(guide.warnings).toBeInstanceOf(Array); // Check command structure const cmd = guide.commands.overview[0]; expect(cmd.cmd).toBeTruthy(); expect(cmd.desc).toBeTruthy(); }); }); // Sprint 6: Change includeGuide default from true to false describe('Sprint 6: includeGuide default=false (RED phase - these tests will fail initially)', () => { // Test 1: Default behavior (no includeGuide parameter) should NOT include guide it('should NOT include usageGuide by default when includeGuide parameter is omitted (new behavior)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-1', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-1' }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); // Will FAIL with current default=true expect(result.activePlan?.planId).toBe(created.planId); expect(result.activePlan?.plan).toBeDefined(); }); // Test 2: Explicit includeGuide=false should NOT include guide it('should NOT include usageGuide when includeGuide=false (existing behavior)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-2', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-2', includeGuide: false, }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); // Should PASS already }); // Test 3: Explicit includeGuide=true should include guide (backward compatibility) it('should include usageGuide when includeGuide=true (backward compatibility)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-3', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-3', includeGuide: true, }); expect(result.activePlan).toBeDefined(); if (result.activePlan === null) throw new Error('ActivePlan should be defined'); expect(result.activePlan.usageGuide).toBeDefined(); // Should PASS with any default if (result.activePlan.usageGuide === undefined) throw new Error('UsageGuide should be defined'); expect(result.activePlan.usageGuide.quickStart).toContain('phase get_tree'); }); // Test 4: Measure payload size difference it('should have significant payload reduction without guide (~500 bytes vs ~3000 bytes)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-4', }); // Get result WITHOUT guide (new default) const resultWithoutGuide = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-4', includeGuide: false, }); const sizeWithoutGuide = JSON.stringify(resultWithoutGuide).length; // Get result WITH guide const resultWithGuide = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-4', includeGuide: true, }); const sizeWithGuide = JSON.stringify(resultWithGuide).length; // Verify size difference is significant (guide adds ~2.5KB) expect(sizeWithoutGuide).toBeLessThan(1000); // ~500 bytes expect(sizeWithGuide).toBeGreaterThan(2500); // ~3000 bytes expect(sizeWithGuide - sizeWithoutGuide).toBeGreaterThan(2000); // Difference > 2KB (5x reduction) }); // Test 5: All other fields should be present without guide it('should include all other fields (planId, plan, lastUpdated) when guide is excluded', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-5', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-5', includeGuide: false, }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.planId).toBe(created.planId); expect(result.activePlan?.plan).toBeDefined(); expect(result.activePlan?.plan.name).toBe('Test Plan'); expect(result.activePlan?.lastUpdated).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); }); // Test 6: includeGuide=null should use default (false) it('should NOT include guide when includeGuide=null (uses default=false)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-6', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-6', includeGuide: null as unknown as boolean, // Explicitly pass null }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); // Will FAIL with current default=true }); // Test 7: includeGuide=undefined should use default (false) it('should NOT include guide when includeGuide=undefined (uses default=false)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-7', }); const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-7', includeGuide: undefined, }); expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); // Will FAIL with current default=true }); // Test 8: includeGuide as string should be handled with strict type checking it('should use strict type checking for includeGuide (only boolean true includes guide)', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-8', }); // TypeScript compilation prevents this, but test runtime behavior // String 'true' is NOT strictly equal to boolean true, so guide should NOT be included const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-8', includeGuide: 'true' as unknown as boolean, }); // Sprint 6: Strict checking (=== true) means string 'true' does NOT include guide expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); }); // Test 9: Backward compatibility - existing code with includeGuide=true it('should support existing clients that explicitly pass includeGuide=true', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-9', }); // Simulate existing client code that explicitly requests guide const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-9', includeGuide: true, }); expect(result.activePlan).toBeDefined(); if (result.activePlan === null) throw new Error('ActivePlan should be defined'); expect(result.activePlan.usageGuide).toBeDefined(); // Should ALWAYS work if (result.activePlan.usageGuide === undefined) throw new Error('UsageGuide should be defined'); expect(result.activePlan.usageGuide.commands).toBeDefined(); }); // Test 10: Verify default behavior matches new expectation it('should confirm new default behavior: omitted parameter = no guide', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test' }); await service.setActivePlan({ planId: created.planId, workspacePath: '/test/workspace-sprint6-10', }); // Call without includeGuide parameter at all const result = await service.getActivePlan({ workspacePath: '/test/workspace-sprint6-10', }); // After implementation, omitting includeGuide should mean NO guide (default=false) expect(result.activePlan).toBeDefined(); expect(result.activePlan?.usageGuide).toBeUndefined(); // Will FAIL until default changes // But all other data should be present expect(result.activePlan?.planId).toBe(created.planId); expect(result.activePlan?.plan.name).toBe('Test Plan'); }); }); describe('minimal return values (Sprint 6)', () => { describe('createPlan should return only planId', () => { it('should not include manifest in result', async () => { const result = await service.createPlan({ name: 'Test Plan', description: 'Test', }); expect(result.planId).toBeDefined(); expect(result).not.toHaveProperty('manifest'); }); it('should not include createdAt in result', async () => { const result = await service.createPlan({ name: 'Test Plan', description: 'Test', }); expect(result.planId).toBeDefined(); expect(result).not.toHaveProperty('createdAt'); }); }); describe('updatePlan should return only success and planId', () => { it('should not include full plan object in result', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); const result = await service.updatePlan({ planId: created.planId, updates: { name: 'Updated Plan' }, }); expect(result.success).toBe(true); expect(result).not.toHaveProperty('plan'); }); it('should not include updatedAt in result', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); const result = await service.updatePlan({ planId: created.planId, updates: { name: 'Updated Plan' }, }); expect(result.success).toBe(true); expect(result).not.toHaveProperty('updatedAt'); }); }); }); // Sprint 1: PlanService Validation (Bugs #1, #2, #3) // RED phase - these tests should FAIL initially until validation is implemented describe('Sprint 1: PlanService name and status validation', () => { describe('createPlan name validation', () => { it('should reject empty name', async () => { await expect(service.createPlan({ name: '', description: 'Test description', })).rejects.toThrow('name must be a non-empty string'); }); it('should reject whitespace-only name', async () => { await expect(service.createPlan({ name: ' ', description: 'Test description', })).rejects.toThrow('name must be a non-empty string'); }); it('should reject name with only tabs and newlines', async () => { await expect(service.createPlan({ name: '\t\n\r', description: 'Test description', })).rejects.toThrow('name must be a non-empty string'); }); it('should accept valid name with leading/trailing spaces (trimmed check)', async () => { // Name with spaces around it should be accepted (spaces are trimmed for validation) const result = await service.createPlan({ name: ' Valid Plan Name ', description: 'Test description', }); expect(result.planId).toBeDefined(); }); }); describe('updatePlan name validation', () => { it('should reject empty name on update', async () => { const created = await service.createPlan({ name: 'Original Name', description: 'Test', }); await expect(service.updatePlan({ planId: created.planId, updates: { name: '' }, })).rejects.toThrow('name must be a non-empty string'); }); it('should reject whitespace-only name on update', async () => { const created = await service.createPlan({ name: 'Original Name', description: 'Test', }); await expect(service.updatePlan({ planId: created.planId, updates: { name: ' ' }, })).rejects.toThrow('name must be a non-empty string'); }); }); describe('updatePlan status validation', () => { it('should reject invalid status value', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); await expect(service.updatePlan({ planId: created.planId, updates: { status: 'invalid_status' as PlanStatus }, })).rejects.toThrow('status must be one of: active, archived, completed'); }); it('should reject empty string as status', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); await expect(service.updatePlan({ planId: created.planId, updates: { status: '' as PlanStatus }, })).rejects.toThrow('status must be one of: active, archived, completed'); }); it('should accept valid status values', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); // Test all valid statuses for (const status of ['active', 'archived', 'completed'] as PlanStatus[]) { await service.updatePlan({ planId: created.planId, updates: { status }, }); const { plan } = await service.getPlan({ planId: created.planId }); expect(plan.manifest.status).toBe(status); } }); }); }); describe('getSummary', () => { it('should return plan manifest info', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test Description', }); const result = await service.getSummary({ planId: created.planId }); expect(result.plan.id).toBe(created.planId); expect(result.plan.name).toBe('Test Plan'); expect(result.plan.description).toBe('Test Description'); expect(result.plan.status).toBe('active'); }); it('should return statistics from manifest', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); const result = await service.getSummary({ planId: created.planId }); expect(result.statistics).toBeDefined(); expect(result.statistics.totalPhases).toBe(0); expect(result.statistics.totalRequirements).toBe(0); expect(result.statistics.totalSolutions).toBe(0); expect(result.statistics.totalDecisions).toBe(0); expect(result.statistics.totalArtifacts).toBe(0); expect(result.statistics.completionPercentage).toBe(0); }); it('should return phase summaries array', async () => { const created = await service.createPlan({ name: 'Test Plan', description: 'Test', }); // Add phases via repository // eslint-disable-next-line @typescript-eslint/no-explicit-any const phaseRepo = repositoryFactory.createRepository<any>('phase', created.planId); const phase1 = { id: 'phase-1', type: 'phase' as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1, metadata: { createdBy: 'test', tags: [], annotations: [] }, title: 'Phase 1', description: 'First phase', status: 'in_progress' as const, progress: 50, order: 1, path: '1', depth: 0, }; const phase2 = { id: 'phase-2', type: 'phase' as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1, metadata: { createdBy: 'test', tags: [], annotations: [] }, title: 'Phase 2', description: 'Second phase', status: 'planned' as const, progress: 0, order: 2, path: '2', depth: 0, parentId: 'phase-1', }; await phaseRepo.create(phase1); await phaseRepo.create(phase2); const result = await service.getSummary({ planId: created.planId }); expect(result.phases).toHaveLength(2); expect(result.phases[0]).toEqual({ id: 'phase-1', title: 'Phase 1', status: 'in_progress', progress: 50, path: '1', childCount: 1, }); expect(result.phases[1]).toEqual({ id: 'phase-2', title: 'Phase 2', status: 'planned', progress: 0, path: '2', childCount: 0, }); }); it('should throw error for non-existent plan', async () => { await expect(service.getSummary({ planId: 'non-existent' })) .rejects.toThrow('Plan not found'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server