Skip to main content
Glama
batch-integration.test.ts17.8 kB
/** * Integration Tests: BatchService * * Tests 32-40 cover real FileStorage integration: * - Disk persistence * - Statistics updates * - Rollback integrity * - Large batches * - Dependency trees */ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { BatchService } from '../../src/domain/services/batch-service.js'; import { PlanService } from '../../src/domain/services/plan-service.js'; import { RequirementService } from '../../src/domain/services/requirement-service.js'; import { SolutionService } from '../../src/domain/services/solution-service.js'; import { PhaseService } from '../../src/domain/services/phase-service.js'; import { LinkingService } from '../../src/domain/services/linking-service.js'; import { DecisionService } from '../../src/domain/services/decision-service.js'; import { ArtifactService } from '../../src/domain/services/artifact-service.js'; import { RepositoryFactory } from '../../src/infrastructure/factory/repository-factory.js'; import { FileLockManager } from '../../src/infrastructure/repositories/file/file-lock-manager.js'; import type { Requirement, Solution, Phase, Decision, Artifact, Entity, Link } from '../../src/domain/entities/types.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; // Helper functions to replace storage.loadEntities/loadLinks async function loadEntities<T extends Entity>( repositoryFactory: RepositoryFactory, planId: string, entityType: 'requirements' | 'solutions' | 'phases' | 'decisions' | 'artifacts' ): Promise<T[]> { const typeMap: Record<string, string> = { requirements: 'requirement', solutions: 'solution', phases: 'phase', decisions: 'decision', artifacts: 'artifact' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument const repo = repositoryFactory.createRepository<T>(typeMap[entityType] as any, planId); return repo.findAll(); } async function loadLinks( repositoryFactory: RepositoryFactory, planId: string ): Promise<Link[]> { const linkRepo = repositoryFactory.createLinkRepository(planId); return linkRepo.findAllLinks(); } // Helper to retry directory removal on Windows (EBUSY/ENOTEMPTY errors) async function removeDirectoryWithRetry(dir: string, maxRetries = 3): Promise<void> { for (let i = 0; i < maxRetries; i++) { try { await fs.rm(dir, { recursive: true, force: true }); return; } catch (error: unknown) { if (i === maxRetries - 1) throw error; // Wait before retry (exponential backoff) await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); } } } describe('BatchService - Integration Tests', () => { let batchService: BatchService; let planService: PlanService; let requirementService: RequirementService; let solutionService: SolutionService; let phaseService: PhaseService; let linkingService: LinkingService; let decisionService: DecisionService; let artifactService: ArtifactService; let repositoryFactory: RepositoryFactory; let lockManager: FileLockManager; let testDir: string; let testPlanId: string; beforeEach(async () => { // Create temporary directory for tests testDir = path.join(os.tmpdir(), `mcp-batch-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(); // Initialize all services planService = new PlanService(repositoryFactory); requirementService = new RequirementService(repositoryFactory, planService); solutionService = new SolutionService(repositoryFactory, planService); phaseService = new PhaseService(repositoryFactory, planService); linkingService = new LinkingService(repositoryFactory); decisionService = new DecisionService(repositoryFactory, planService); artifactService = new ArtifactService(repositoryFactory, planService); batchService = new BatchService( repositoryFactory, planService, requirementService, solutionService, phaseService, linkingService, decisionService, artifactService ); // Create test plan const { planId } = await planService.createPlan({ name: 'Batch Integration Test Plan', description: 'Testing batch operations with real storage', }); testPlanId = planId; }); afterEach(async () => { await repositoryFactory.dispose(); await lockManager.dispose(); await removeDirectoryWithRetry(testDir); }); it('Test 32: Real FileStorage integration - batch creates entities on disk', async () => { const result = await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { title: 'Req 1', description: 'First requirement', source: { type: 'user-request' }, acceptanceCriteria: ['AC1'], priority: 'high', category: 'functional', }, }, { entityType: 'requirement', payload: { title: 'Req 2', description: 'Second requirement', source: { type: 'user-request' }, acceptanceCriteria: ['AC2'], priority: 'medium', category: 'functional', }, }, ], }); // Verify batch succeeded expect(result.results).toHaveLength(2); expect(result.results[0].success).toBe(true); expect(result.results[1].success).toBe(true); // Verify entities persisted to disk by reading directly const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); expect(requirements).toHaveLength(2); expect(requirements[0].title).toBe('Req 1'); expect(requirements[1].title).toBe('Req 2'); }); it('Test 33: Multiple batches sequentially - verify persistence', async () => { // First batch await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { title: 'Batch 1 Req', description: 'From first batch', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, ], }); // Second batch await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { title: 'Batch 2 Req', description: 'From second batch', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'medium', category: 'functional', }, }, ], }); // Verify both batches persisted const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); expect(requirements).toHaveLength(2); expect(requirements[0].title).toBe('Batch 1 Req'); expect(requirements[1].title).toBe('Batch 2 Req'); }); it('Test 34: Batch rollback does not write to disk', async () => { try { await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { title: 'Valid Req', description: 'This should work', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, { entityType: 'requirement', payload: { // Missing required fields title: '', description: '', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, ], }); throw new Error('Should have thrown validation error'); } catch (error: unknown) { expect((error as Error).message).toContain('title must be a non-empty string'); } // Verify nothing was written to disk const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); expect(requirements).toHaveLength(0); }); it('Test 35: Statistics updated once after batch', async () => { await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { title: 'Req 1', description: 'First', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, { entityType: 'requirement', payload: { title: 'Req 2', description: 'Second', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, { entityType: 'requirement', payload: { title: 'Req 3', description: 'Third', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, ], }); // Verify statistics updated const { plan } = await planService.getPlan({ planId: testPlanId }); expect(plan.manifest.statistics.totalRequirements).toBe(3); }); it('Test 36: Temp IDs in links persist correctly', async () => { const result = await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { tempId: '$0', title: 'Parent Req', description: 'Parent', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, { entityType: 'solution', payload: { tempId: '$1', title: 'Solution', description: 'Implements parent', addressing: ['$0'], approach: 'Test approach', }, }, { entityType: 'link', payload: { sourceId: '$1', targetId: '$0', relationType: 'implements', }, }, ], }); // Verify link persisted with resolved IDs const links = await loadLinks(repositoryFactory, testPlanId); expect(links).toHaveLength(1); expect(links[0].sourceId).toBe(result.results[1].id); // Solution ID expect(links[0].targetId).toBe(result.results[0].id); // Requirement ID expect(links[0].relationType).toBe('implements'); }); it('Test 37: Large batch (50 entities) succeeds', async () => { const operations = []; for (let i = 0; i < 50; i++) { operations.push({ entityType: 'requirement' as const, payload: { title: `Requirement ${i.toString()}`, description: `Description ${i.toString()}`, source: { type: 'user-request' as const }, acceptanceCriteria: [], priority: 'medium' as const, category: 'functional' as const, }, }); } const result = await batchService.executeBatch({ planId: testPlanId, operations, }); expect(result.results).toHaveLength(50); expect(result.results.every((r) => r.success)).toBe(true); // Verify all persisted const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); expect(requirements).toHaveLength(50); }, 30000); // Increase timeout for large batch operation under parallel test load it('Test 38: Batch creates full dependency tree', async () => { const result = await batchService.executeBatch({ planId: testPlanId, operations: [ // Requirements { entityType: 'requirement', payload: { tempId: '$0', title: 'Main Requirement', description: 'Top level requirement', source: { type: 'user-request' }, acceptanceCriteria: ['AC1'], priority: 'high', category: 'functional', }, }, // Solution { entityType: 'solution', payload: { tempId: '$1', title: 'Implementation Solution', description: 'How to implement', addressing: ['$0'], approach: 'TDD approach', }, }, // Phases { entityType: 'phase', payload: { tempId: '$2', title: 'Phase 1', description: 'First phase', objectives: ['Implement feature'], deliverables: ['Working code'], }, }, { entityType: 'phase', payload: { tempId: '$3', title: 'Phase 1.1', description: 'Sub-phase', parentId: '$2', objectives: ['Sub-task'], deliverables: ['Sub-deliverable'], }, }, // Links { entityType: 'link', payload: { sourceId: '$1', targetId: '$0', relationType: 'implements', }, }, { entityType: 'link', payload: { sourceId: '$2', targetId: '$0', relationType: 'addresses', }, }, ], }); // Verify all entities created expect(result.results).toHaveLength(6); expect(result.results.every((r) => r.success)).toBe(true); // Verify dependency tree persisted const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); const solutions = await loadEntities<Solution>(repositoryFactory, testPlanId, 'solutions'); const phases = await loadEntities<Phase>(repositoryFactory, testPlanId, 'phases'); const links = await loadLinks(repositoryFactory, testPlanId); expect(requirements).toHaveLength(1); expect(solutions).toHaveLength(1); expect(phases).toHaveLength(2); expect(links).toHaveLength(2); // Verify phase hierarchy const phase1 = phases.find((p) => p.title === 'Phase 1'); const phase11 = phases.find((p) => p.title === 'Phase 1.1'); expect(phase11).toBeDefined(); expect(phase1).toBeDefined(); if (!phase11 || !phase1) throw new Error('Phases should be defined'); expect(phase11.parentId).toBe(phase1.id); }); it('Test 39: Batch with all entity types', async () => { const result = await batchService.executeBatch({ planId: testPlanId, operations: [ { entityType: 'requirement', payload: { tempId: '$0', title: 'Requirement', description: 'Test requirement', source: { type: 'user-request' }, acceptanceCriteria: [], priority: 'high', category: 'functional', }, }, { entityType: 'solution', payload: { tempId: '$1', title: 'Solution', description: 'Test solution', addressing: ['$0'], approach: 'Approach', }, }, { entityType: 'phase', payload: { tempId: '$2', title: 'Phase', description: 'Test phase', objectives: [], deliverables: [], }, }, { entityType: 'decision', payload: { tempId: '$3', title: 'Decision', question: 'What to do?', context: 'Context', decision: 'Do it', consequences: 'Good things', }, }, { entityType: 'artifact', payload: { tempId: '$4', title: 'Artifact', description: 'Test artifact', artifactType: 'code', relatedPhaseId: '$2', }, }, { entityType: 'link', payload: { sourceId: '$1', targetId: '$0', relationType: 'implements', }, }, ], }); expect(result.results).toHaveLength(6); expect(result.results.every((r) => r.success)).toBe(true); // Verify all persisted const requirements = await loadEntities<Requirement>(repositoryFactory, testPlanId, 'requirements'); const solutions = await loadEntities<Solution>(repositoryFactory, testPlanId, 'solutions'); const phases = await loadEntities<Phase>(repositoryFactory, testPlanId, 'phases'); const decisions = await loadEntities<Decision>(repositoryFactory, testPlanId, 'decisions'); const artifacts = await loadEntities<Artifact>(repositoryFactory, testPlanId, 'artifacts'); const links = await loadLinks(repositoryFactory, testPlanId); expect(requirements).toHaveLength(1); expect(solutions).toHaveLength(1); expect(phases).toHaveLength(1); expect(decisions).toHaveLength(1); expect(artifacts).toHaveLength(1); expect(links).toHaveLength(1); }); });

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