Skip to main content
Glama
solution-service.test.ts43.4 kB
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { SolutionService } from '../../src/domain/services/solution-service.js'; import { RequirementService } from '../../src/domain/services/requirement-service.js'; import { DecisionService } from '../../src/domain/services/decision-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('SolutionService', () => { let service: SolutionService; let requirementService: RequirementService; let decisionService: DecisionService; let planService: PlanService; let repositoryFactory: RepositoryFactory; let lockManager: FileLockManager; let testDir: string; let planId: string; // Helper IDs for real entities (created in beforeEach) let req1Id: string; let req2Id: string; beforeEach(async () => { testDir = path.join(os.tmpdir(), `mcp-sol-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); decisionService = new DecisionService(repositoryFactory, planService); service = new SolutionService(repositoryFactory, planService, undefined, decisionService); requirementService = new RequirementService(repositoryFactory, planService); const plan = await planService.createPlan({ name: 'Test Plan', description: 'For testing solutions', }); planId = plan.planId; // Create real requirements for testing const req1 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 1', description: 'First test requirement', category: 'functional', priority: 'high', acceptanceCriteria: [], source: { type: 'user-request' }, }, }); req1Id = req1.requirementId; const req2 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 2', description: 'Second test requirement', category: 'functional', priority: 'medium', acceptanceCriteria: [], source: { type: 'user-request' }, }, }); req2Id = req2.requirementId; }); afterEach(async () => { await repositoryFactory.dispose(); await lockManager.dispose(); await fs.rm(testDir, { recursive: true, force: true }); }); describe('propose_solution', () => { // RED: Validation tests for REQUIRED fields describe('title validation (REQUIRED field)', () => { it('RED: should reject missing title (undefined)', async () => { await expect(service.proposeSolution({ planId, solution: { // @ts-expect-error - Testing invalid input title: undefined, description: 'Test', approach: 'Test', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, })).rejects.toThrow('title is required'); }); it('RED: should reject empty title', async () => { await expect(service.proposeSolution({ planId, solution: { title: '', description: 'Test', approach: 'Test', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, })).rejects.toThrow('title must be a non-empty string'); }); it('RED: should reject whitespace-only title', async () => { await expect(service.proposeSolution({ planId, solution: { title: ' ', description: 'Test', approach: 'Test', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, })).rejects.toThrow('title must be a non-empty string'); }); }); // GREEN: Tests for minimal solution with defaults describe('minimal solution with defaults', () => { it('GREEN: should accept minimal solution (title only)', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'My Solution', }, }); expect(result.solutionId).toBeDefined(); // Verify defaults were applied const { solution } = await service.getSolution({ planId, solutionId: result.solutionId, fields: ['*'] }); expect(solution.title).toBe('My Solution'); expect(solution.description).toBe(''); // default expect(solution.approach).toBe(''); // default expect(solution.tradeoffs).toEqual([]); // default expect(solution.addressing).toEqual([]);// default expect(solution.status).toBe('proposed'); }); }); it('should add a new solution', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Use jsonwebtoken', description: 'JWT library for auth', approach: 'Install and configure', tradeoffs: [ { aspect: 'Security', pros: ['Battle-tested'], cons: ['Dependency'], score: 8 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 4, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low risk', }, }, }); expect(result.solutionId).toBeDefined(); // Verify via getSolution const { solution } = await service.getSolution({ planId, solutionId: result.solutionId, fields: ['*'] }); expect(solution.title).toBe('Use jsonwebtoken'); expect(solution.status).toBe('proposed'); }); it('should store tradeoffs', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Solution', description: 'Desc', approach: 'Approach', tradeoffs: [ { aspect: 'Performance', pros: ['Fast'], cons: ['Memory'], score: 7 }, { aspect: 'Maintainability', pros: ['Clean'], cons: ['Complex'], score: 6 }, ], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'days', confidence: 'medium' }, technicalFeasibility: 'medium', riskAssessment: 'Medium', }, }, }); // Verify via getSolution const { solution } = await service.getSolution({ planId, solutionId: result.solutionId, fields: ['*'] }); expect(solution.tradeoffs).toHaveLength(2); expect(solution.tradeoffs[0].score).toBe(7); }); // BUG #5: Foreign key validation for addressing[] references describe('addressing[] validation (BUG #5 - Sprint 4)', () => { it('RED: should reject non-existent requirement ID in addressing[]', async () => { await expect(service.proposeSolution({ planId, solution: { title: 'Solution with fake requirement', description: 'Test', approach: 'Test', addressing: ['non-existent-requirement-id'], }, })).rejects.toThrow(/Requirement.*non-existent-requirement-id.*not found/i); }); it('RED: should reject multiple non-existent requirement IDs', async () => { await expect(service.proposeSolution({ planId, solution: { title: 'Solution with fake requirements', addressing: ['fake-req-1', 'fake-req-2'], }, })).rejects.toThrow(/Requirement.*not found/i); }); it('GREEN: should accept valid requirement ID in addressing[]', async () => { // Create a real requirement first const req = await requirementService.addRequirement({ planId, requirement: { title: 'Real Requirement', description: 'Test requirement', category: 'functional', priority: 'high', acceptanceCriteria: [], source: { type: 'user-request' }, }, }); // Should succeed with valid requirement ID const result = await service.proposeSolution({ planId, solution: { title: 'Solution with real requirement', addressing: [req.requirementId], }, }); expect(result.solutionId).toBeDefined(); const { solution } = await service.getSolution({ planId, solutionId: result.solutionId }); expect(solution.addressing).toContain(req.requirementId); }); it('GREEN: should accept empty addressing[] (no validation needed)', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Solution without addressing', addressing: [], }, }); expect(result.solutionId).toBeDefined(); }); it('GREEN: should accept undefined addressing (defaults to [])', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Solution with undefined addressing', }, }); expect(result.solutionId).toBeDefined(); }); it('RED: should reject mix of valid and invalid requirement IDs', async () => { // Create a real requirement const req = await requirementService.addRequirement({ planId, requirement: { title: 'Real Requirement', description: 'Test', category: 'functional', priority: 'medium', acceptanceCriteria: [], source: { type: 'user-request' }, }, }); // Mix of valid and invalid IDs should fail await expect(service.proposeSolution({ planId, solution: { title: 'Solution with mixed addressing', addressing: [req.requirementId, 'non-existent-id'], }, })).rejects.toThrow(/Requirement.*non-existent-id.*not found/i); }); }); }); describe('compare_solutions', () => { let sol1Id: string; let sol2Id: string; let sol3Id: string; beforeEach(async () => { const s1 = await service.proposeSolution({ planId, solution: { title: 'jsonwebtoken', description: 'JWT lib', approach: 'npm install', tradeoffs: [ { aspect: 'Security', pros: ['Tested'], cons: ['Dep'], score: 8 }, { aspect: 'Performance', pros: ['Fast'], cons: [], score: 9 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 4, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); sol1Id = s1.solutionId; const s2 = await service.proposeSolution({ planId, solution: { title: 'jose', description: 'Modern JWT', approach: 'npm install jose', tradeoffs: [ { aspect: 'Security', pros: ['Modern'], cons: ['New'], score: 7 }, { aspect: 'Performance', pros: ['Async'], cons: ['Overhead'], score: 7 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 6, unit: 'hours', confidence: 'medium' }, technicalFeasibility: 'high', riskAssessment: 'Medium', }, }, }); sol2Id = s2.solutionId; const s3 = await service.proposeSolution({ planId, solution: { title: 'passport-jwt', description: 'Passport JWT', approach: 'npm install passport', tradeoffs: [ { aspect: 'Security', pros: ['Passport ecosystem'], cons: ['Heavy'], score: 6 }, { aspect: 'Performance', pros: [], cons: ['Slow'], score: 5 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 8, unit: 'hours', confidence: 'low' }, technicalFeasibility: 'medium', riskAssessment: 'High', }, }, }); sol3Id = s3.solutionId; }); it('should compare multiple solutions', async () => { const result = await service.compareSolutions({ planId, solutionIds: [sol1Id, sol2Id, sol3Id], }); expect(result.comparison.solutions).toHaveLength(3); expect(result.comparison.matrix.length).toBeGreaterThan(0); }); it('should identify best solution', async () => { const result = await service.compareSolutions({ planId, solutionIds: [sol1Id, sol2Id, sol3Id], }); expect(result.comparison.summary.bestOverall).toBe(sol1Id); }); it('should filter by aspects', async () => { const result = await service.compareSolutions({ planId, solutionIds: [sol1Id, sol2Id], aspects: ['Security'], }); expect(result.comparison.matrix).toHaveLength(1); expect(result.comparison.matrix[0].aspect).toBe('Security'); }); it('should throw error when solutionIds is undefined', async () => { await expect( service.compareSolutions({ planId, solutionIds: undefined as unknown as string[], }) ).rejects.toThrow('solutionIds must be a non-empty array'); }); it('should throw error when solutionIds is empty array', async () => { await expect( service.compareSolutions({ planId, solutionIds: [], }) ).rejects.toThrow('solutionIds must be a non-empty array'); }); it('should throw error when solutionIds is not an array', async () => { await expect( service.compareSolutions({ planId, solutionIds: 'not-an-array' as unknown as string[], }) ).rejects.toThrow('solutionIds must be a non-empty array'); }); // RED: BUG #6 - compareSolutions crashes when solution has undefined tradeoffs it('should handle solutions with missing tradeoffs field', async () => { // Create a solution with missing tradeoffs (simulating corrupted data or legacy solution) const repo = repositoryFactory.createRepository('solution', planId); const solutionWithMissingTradeoffs = { id: 'sol-missing-tradeoffs', type: 'solution' as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1, metadata: { createdBy: 'test', tags: [], annotations: [] }, title: 'Solution Without Tradeoffs', description: 'This solution has no tradeoffs field', approach: 'Test approach', addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours' as const, confidence: 'high' as const }, technicalFeasibility: 'high' as const, riskAssessment: 'Low', }, status: 'proposed' as const, // NOTE: tradeoffs field is intentionally missing }; await repo.create(solutionWithMissingTradeoffs); // Create a normal solution const normalSolution = await service.proposeSolution({ planId, solution: { title: 'Normal Solution', description: 'Has tradeoffs', approach: 'Normal approach', tradeoffs: [{ aspect: 'Test', pros: ['Good'], cons: ['Bad'], score: 5 }], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); // This should NOT crash - it should handle missing tradeoffs gracefully const result = await service.compareSolutions({ planId, solutionIds: ['sol-missing-tradeoffs', normalSolution.solutionId], }); expect(result.comparison.solutions).toHaveLength(2); expect(result.comparison.matrix.length).toBeGreaterThanOrEqual(0); }); }); describe('select_solution', () => { it('should mark solution as selected', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'Selected Solution', description: 'Will be selected', approach: 'Approach', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); await service.selectSolution({ planId, solutionId: proposed.solutionId, reason: 'Best fit', }); // Verify via getSolution const { solution } = await service.getSolution({ planId, solutionId: proposed.solutionId, fields: ['*'] }); expect(solution.status).toBe('selected'); expect(solution.selectionReason).toBe('Best fit'); }); it('should deselect other solutions for same requirement', async () => { const s1 = await service.proposeSolution({ planId, solution: { title: 'Solution 1', description: 'First', approach: 'A', tradeoffs: [], addressing: [req1Id], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const s2 = await service.proposeSolution({ planId, solution: { title: 'Solution 2', description: 'Second', approach: 'B', tradeoffs: [], addressing: [req1Id], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); // Select first await service.selectSolution({ planId, solutionId: s1.solutionId }); // Select second const result = await service.selectSolution({ planId, solutionId: s2.solutionId }); // Verify via getSolution const { solution } = await service.getSolution({ planId, solutionId: s2.solutionId, fields: ['*'] }); expect(solution.status).toBe('selected'); expect(result.deselectedIds).toHaveLength(1); if (result.deselectedIds === undefined || result.deselectedIds.length === 0) { throw new Error('DeselectedIds should be defined and not empty'); } expect(result.deselectedIds[0]).toBe(s1.solutionId); }); }); // TDD Sprint: Solution-to-Decision Auto-Creation describe('select_solution with createDecisionRecord', () => { it('should automatically create Decision when createDecisionRecord=true', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'Use JWT Authentication', description: 'Implement JWT-based authentication', approach: 'Use jsonwebtoken library with secure token generation', tradeoffs: [ { aspect: 'Security', pros: ['Stateless', 'Scalable'], cons: ['Token management'], score: 8 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 8, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low risk - well established pattern', }, }, }); const result = await service.selectSolution({ planId, solutionId: proposed.solutionId, reason: 'Best security and scalability', createDecisionRecord: true, }); expect(result.success).toBe(true); expect(result.decisionId).toBeDefined(); // Verify Decision was created const decisions = await decisionService.listDecisions({ planId }); expect(decisions.total).toBe(1); expect(decisions.decisions[0].title).toContain('JWT Authentication'); }); it('should NOT create Decision when createDecisionRecord=false', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'Solution A', description: 'Description A', approach: 'Approach A', tradeoffs: [], addressing: [req1Id], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.selectSolution({ planId, solutionId: proposed.solutionId, createDecisionRecord: false, }); expect(result.success).toBe(true); expect(result.decisionId).toBeUndefined(); // Verify no Decision was created const decisions = await decisionService.listDecisions({ planId }); expect(decisions.total).toBe(0); }); it('should NOT create Decision when createDecisionRecord is undefined (backward compatibility)', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'Solution B', description: 'Description B', approach: 'Approach B', tradeoffs: [], addressing: [req2Id], evaluation: { effortEstimate: { value: 2, unit: 'hours', confidence: 'medium' }, technicalFeasibility: 'medium', riskAssessment: 'Medium', }, }, }); const result = await service.selectSolution({ planId, solutionId: proposed.solutionId, reason: 'Good choice', }); expect(result.success).toBe(true); expect(result.decisionId).toBeUndefined(); // Verify no Decision was created const decisions = await decisionService.listDecisions({ planId }); expect(decisions.total).toBe(0); }); it('should populate Decision with correct data from Solution', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'GraphQL API', description: 'Modern API with GraphQL', approach: 'Use Apollo Server with TypeScript', implementationNotes: 'Setup resolvers and schema', tradeoffs: [ { aspect: 'Flexibility', pros: ['Query what you need'], cons: ['Learning curve'], score: 9 }, { aspect: 'Performance', pros: ['Efficient'], cons: ['Complexity'], score: 7 }, ], addressing: [req1Id, req2Id], evaluation: { effortEstimate: { value: 3, unit: 'days', confidence: 'medium' }, technicalFeasibility: 'high', riskAssessment: 'Medium risk - team learning required', }, }, }); const result = await service.selectSolution({ planId, solutionId: proposed.solutionId, reason: 'Future-proof and flexible', createDecisionRecord: true, }); if (result.decisionId === undefined) throw new Error('DecisionId should be defined'); const { decision } = await decisionService.getDecision({ planId, decisionId: result.decisionId, fields: ['*'], }); // Verify Decision fields expect(decision.title).toContain('GraphQL API'); expect(decision.question).toContain('solution'); expect(decision.context).toContain('Modern API with GraphQL'); expect(decision.context).toContain('Apollo Server'); expect(decision.decision).toContain('GraphQL API'); expect(decision.consequences).toContain('risk'); expect(decision.impactScope).toContain(req1Id); expect(decision.impactScope).toContain(req2Id); expect(decision.status).toBe('active'); }); it('should include deselected solutions in alternativesConsidered', async () => { // Create three solutions for the same requirement await service.proposeSolution({ planId, solution: { title: 'REST API', description: 'Traditional REST', approach: 'Express.js REST endpoints', tradeoffs: [ { aspect: 'Simplicity', pros: ['Well known'], cons: ['Over-fetching'], score: 6 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 2, unit: 'days', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const sol2 = await service.proposeSolution({ planId, solution: { title: 'GraphQL', description: 'Modern GraphQL', approach: 'Apollo Server', tradeoffs: [ { aspect: 'Flexibility', pros: ['Efficient'], cons: ['Complex'], score: 9 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 3, unit: 'days', confidence: 'medium' }, technicalFeasibility: 'high', riskAssessment: 'Medium', }, }, }); await service.proposeSolution({ planId, solution: { title: 'gRPC', description: 'High performance gRPC', approach: 'Protocol Buffers', tradeoffs: [ { aspect: 'Performance', pros: ['Very fast'], cons: ['Steep learning'], score: 7 }, ], addressing: [req1Id], evaluation: { effortEstimate: { value: 5, unit: 'days', confidence: 'low' }, technicalFeasibility: 'medium', riskAssessment: 'High', }, }, }); // Select GraphQL - should deselect REST and gRPC const result = await service.selectSolution({ planId, solutionId: sol2.solutionId, reason: 'Best balance of flexibility and feasibility', createDecisionRecord: true, }); if (result.decisionId === undefined) throw new Error('DecisionId should be defined'); const { decision } = await decisionService.getDecision({ planId, decisionId: result.decisionId, fields: ['*'], }); // Verify alternativesConsidered contains deselected solutions expect(decision.alternativesConsidered).toBeDefined(); expect(decision.alternativesConsidered.length).toBeGreaterThanOrEqual(2); const alternativeTitles = decision.alternativesConsidered.map((alt: { option: string }) => alt.option); expect(alternativeTitles).toContain('REST API'); expect(alternativeTitles).toContain('gRPC'); }); it('should handle selection with no alternatives', async () => { const proposed = await service.proposeSolution({ planId, solution: { title: 'Only Solution', description: 'The only option', approach: 'Do it this way', tradeoffs: [], addressing: [req1Id], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.selectSolution({ planId, solutionId: proposed.solutionId, createDecisionRecord: true, }); if (result.decisionId === undefined) throw new Error('DecisionId should be defined'); const { decision } = await decisionService.getDecision({ planId, decisionId: result.decisionId, fields: ['*'], }); // alternativesConsidered can be empty or minimal expect(decision.alternativesConsidered).toBeDefined(); }); }); describe('list_solutions', () => { it('should list all solutions', async () => { await service.proposeSolution({ planId, solution: { title: 'Sol 1', description: 'D1', approach: 'A', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); await service.proposeSolution({ planId, solution: { title: 'Sol 2', description: 'D2', approach: 'B', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 2, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.listSolutions({ planId }); expect(result.solutions).toHaveLength(2); }); it('should filter by status', async () => { const sol = await service.proposeSolution({ planId, solution: { title: 'To Select', description: 'D', approach: 'A', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); await service.selectSolution({ planId, solutionId: sol.solutionId }); const result = await service.listSolutions({ planId, filters: { status: 'selected' }, }); expect(result.solutions).toHaveLength(1); }); }); describe('delete_solution', () => { it('should delete solution', async () => { const sol = await service.proposeSolution({ planId, solution: { title: 'To Delete', description: 'D', approach: 'A', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.deleteSolution({ planId, solutionId: sol.solutionId, }); expect(result.success).toBe(true); const list = await service.listSolutions({ planId }); expect(list.solutions).toHaveLength(0); }); }); describe('minimal return values (Sprint 6)', () => { describe('proposeSolution should return only solutionId', () => { it('should not include full solution object in result', async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Test Solution', description: 'Test', approach: 'Approach', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); expect(result.solutionId).toBeDefined(); expect(result).not.toHaveProperty('solution'); }); }); describe('updateSolution should return only success and solutionId', () => { it('should not include full solution object in result', async () => { const added = await service.proposeSolution({ planId, solution: { title: 'Test', description: 'D', approach: 'A', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.updateSolution({ planId, solutionId: added.solutionId, updates: { title: 'Updated' }, }); expect(result.success).toBe(true); expect(result).not.toHaveProperty('solution'); }); }); describe('BUG #18: Title validation in updateSolution (TDD - RED phase)', () => { let solutionId: string; beforeEach(async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Original Title', description: 'D', approach: 'A', }, }); solutionId = result.solutionId; }); it('RED: should reject empty title', async () => { await expect(service.updateSolution({ planId, solutionId, updates: { title: '' }, })).rejects.toThrow('title must be a non-empty string'); }); it('RED: should reject whitespace-only title', async () => { await expect(service.updateSolution({ planId, solutionId, updates: { title: ' ' }, })).rejects.toThrow('title must be a non-empty string'); }); it('GREEN: should allow valid title update', async () => { const result = await service.updateSolution({ planId, solutionId, updates: { title: 'New Valid Title' }, }); expect(result.success).toBe(true); const updated = await service.getSolution({ planId, solutionId, }); expect(updated.solution.title).toBe('New Valid Title'); }); }); describe('selectSolution should return only success and IDs', () => { it('should not include full solution objects in result', async () => { const sol = await service.proposeSolution({ planId, solution: { title: 'Test', description: 'D', approach: 'A', tradeoffs: [], addressing: [], evaluation: { effortEstimate: { value: 1, unit: 'hours', confidence: 'high' }, technicalFeasibility: 'high', riskAssessment: 'Low', }, }, }); const result = await service.selectSolution({ planId, solutionId: sol.solutionId, }); expect(result.success).toBe(true); expect(result).not.toHaveProperty('solution'); expect(result).not.toHaveProperty('deselected'); }); }); }); describe('fields parameter support', () => { let solId: string; beforeEach(async () => { const result = await service.proposeSolution({ planId, solution: { title: 'Complete Solution', description: 'Full description', approach: 'Detailed approach', implementationNotes: 'Important implementation notes', addressing: [req1Id], tradeoffs: [ { aspect: 'performance', pros: ['fast'], cons: ['memory'], score: 8 }, ], evaluation: { effortEstimate: { value: 5, unit: 'days', confidence: 'medium' }, technicalFeasibility: 'high', riskAssessment: 'Medium risk', }, }, }); solId = result.solutionId; }); describe('getSolution with fields', () => { it('should return only minimal fields when fields=["id","title"]', async () => { const result = await service.getSolution({ planId, solutionId: solId, fields: ['id', 'title'], }); const sol = result.solution as unknown as Record<string, unknown>; expect(sol.id).toBe(solId); expect(sol.title).toBe('Complete Solution'); expect(sol.description).toBeUndefined(); expect(sol.tradeoffs).toBeUndefined(); }); it('should return ALL fields by default (no fields parameter)', async () => { const result = await service.getSolution({ planId, solutionId: solId, }); const sol = result.solution; expect(sol.id).toBeDefined(); expect(sol.title).toBeDefined(); expect(sol.description).toBeDefined(); expect(sol.status).toBeDefined(); // GET operations should return all fields by default expect(sol.tradeoffs).toBeDefined(); expect(sol.implementationNotes).toBe('Important implementation notes'); expect(sol.evaluation).toBeDefined(); }); it('should return all fields when fields=["*"]', async () => { const result = await service.getSolution({ planId, solutionId: solId, fields: ['*'], }); const sol = result.solution; expect(sol.tradeoffs).toBeDefined(); expect(sol.implementationNotes).toBe('Important implementation notes'); expect(sol.evaluation).toBeDefined(); }); }); describe('listSolutions with fields', () => { it('should return summary fields by default', async () => { const result = await service.listSolutions({ planId, }); expect(result.solutions.length).toBeGreaterThan(0); const sol = result.solutions[0]; expect(sol.id).toBeDefined(); expect(sol.title).toBeDefined(); expect(sol.tradeoffs).toBeUndefined(); }); it('should return minimal fields when fields=["id","title","status"]', async () => { const result = await service.listSolutions({ planId, fields: ['id', 'title', 'status'], }); const sol = result.solutions[0] as unknown as Record<string, unknown>; expect(sol.id).toBeDefined(); expect(sol.title).toBeDefined(); expect(sol.status).toBeDefined(); expect(sol.description).toBeUndefined(); }); }); }); describe('bulk_update (Sprint 9 - RED Phase)', () => { let reqId: string; let sol1Id: string; let sol2Id: string; let sol3Id: string; beforeEach(async () => { // Create a requirement first const req = await requirementService.addRequirement({ planId, requirement: { title: 'Test Requirement', description: 'For testing', category: 'functional', priority: 'high', acceptanceCriteria: [], source: { type: 'user-request' }, }, }); reqId = req.requirementId; // Create test solutions const s1 = await service.proposeSolution({ planId, solution: { title: 'Solution 1', description: 'First solution', approach: 'Approach 1', addressing: [reqId], tradeoffs: [], evaluation: { technicalFeasibility: 'high', effortEstimate: { value: 1, unit: 'days', confidence: 'medium' }, riskAssessment: 'Low risk', }, }, }); sol1Id = s1.solutionId; const s2 = await service.proposeSolution({ planId, solution: { title: 'Solution 2', description: 'Second solution', approach: 'Approach 2', addressing: [reqId], tradeoffs: [], evaluation: { technicalFeasibility: 'medium', effortEstimate: { value: 2, unit: 'days', confidence: 'medium' }, riskAssessment: 'Medium risk', }, }, }); sol2Id = s2.solutionId; const s3 = await service.proposeSolution({ planId, solution: { title: 'Solution 3', description: 'Third solution', approach: 'Approach 3', addressing: [reqId], tradeoffs: [], evaluation: { technicalFeasibility: 'low', effortEstimate: { value: 3, unit: 'days', confidence: 'low' }, riskAssessment: 'High risk', }, }, }); sol3Id = s3.solutionId; }); it('RED 9.9: should update multiple solutions in one call', async () => { const result = await service.bulkUpdateSolutions({ planId, updates: [ { solutionId: sol1Id, updates: { approach: 'Updated Approach 1' } }, { solutionId: sol2Id, updates: { title: 'Updated Solution 2' } }, { solutionId: sol3Id, updates: { description: 'Updated description' } }, ], }); expect(result.updated).toBe(3); expect(result.failed).toBe(0); expect(result.results).toHaveLength(3); const updated1 = await service.getSolution({ planId, solutionId: sol1Id }); expect(updated1.solution.approach).toBe('Updated Approach 1'); const updated2 = await service.getSolution({ planId, solutionId: sol2Id }); expect(updated2.solution.title).toBe('Updated Solution 2'); const updated3 = await service.getSolution({ planId, solutionId: sol3Id }); expect(updated3.solution.description).toBe('Updated description'); }); it('RED 9.10: should return individual results with success/error', async () => { const result = await service.bulkUpdateSolutions({ planId, updates: [ { solutionId: sol1Id, updates: { title: 'Valid Update' } }, { solutionId: 'non-existent-id', updates: { title: 'Invalid' } }, { solutionId: sol3Id, updates: { approach: 'New Approach' } }, ], 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[1].error).toBeDefined(); expect(result.results[2].success).toBe(true); }); it('RED 9.11: should support atomic transaction mode', async () => { await expect( service.bulkUpdateSolutions({ planId, updates: [ { solutionId: sol1Id, updates: { title: 'Updated' } }, { solutionId: 'invalid-id', updates: { title: 'Fail' } }, ], atomic: true, }) ).rejects.toThrow(); // Verify rollback - no changes applied const check = await service.getSolution({ planId, solutionId: sol1Id }); expect(check.solution.title).toBe('Solution 1'); // unchanged }); }); });

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