Skip to main content
Glama
artifact-service.test.ts61.1 kB
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { ArtifactService } from '../../src/domain/services/artifact-service.js'; import { PlanService } from '../../src/domain/services/plan-service.js'; import { PhaseService } from '../../src/domain/services/phase-service.js'; import { RequirementService } from '../../src/domain/services/requirement-service.js'; import { SolutionService } from '../../src/domain/services/solution-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('ArtifactService', () => { let service: ArtifactService; let planService: PlanService; let phaseService: PhaseService; let requirementService: RequirementService; let solutionService: SolutionService; let repositoryFactory: RepositoryFactory; let lockManager: FileLockManager; let testDir: string; let planId: string; beforeEach(async () => { testDir = path.join(os.tmpdir(), `mcp-artifact-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); phaseService = new PhaseService(repositoryFactory, planService); requirementService = new RequirementService(repositoryFactory, planService); solutionService = new SolutionService(repositoryFactory, planService); service = new ArtifactService(repositoryFactory, planService); const plan = await planService.createPlan({ name: 'Test Plan', description: 'For testing artifacts', }); planId = plan.planId; }); afterEach(async () => { await repositoryFactory.dispose(); await lockManager.dispose(); await fs.rm(testDir, { recursive: true, force: true }); }); describe('addArtifact', () => { // RED: Validation tests for REQUIRED fields describe('title validation (REQUIRED field)', () => { it('RED: should reject missing title (undefined)', async () => { await expect(service.addArtifact({ planId, artifact: { // @ts-expect-error - Testing invalid input title: undefined, artifactType: 'code', description: 'Test artifact', }, })).rejects.toThrow('title is required'); }); it('RED: should reject empty title', async () => { await expect(service.addArtifact({ planId, artifact: { title: '', artifactType: 'code', description: 'Test artifact', }, })).rejects.toThrow('title must be a non-empty string'); }); it('RED: should reject whitespace-only title', async () => { await expect(service.addArtifact({ planId, artifact: { title: ' ', artifactType: 'code', description: 'Test artifact', }, })).rejects.toThrow('title must be a non-empty string'); }); }); describe('artifactType validation (REQUIRED field)', () => { it('RED: should reject missing artifactType (undefined)', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test Artifact', // @ts-expect-error - Testing invalid input artifactType: undefined, description: 'Test artifact', }, })).rejects.toThrow('artifactType is required'); }); it('RED: should reject invalid artifactType', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test Artifact', // @ts-expect-error - Testing invalid input artifactType: 'invalid-type', description: 'Test artifact', }, })).rejects.toThrow('artifactType must be one of: code, config, migration, documentation, test, script, other'); }); }); // GREEN: Tests for minimal artifact with defaults describe('minimal artifact with defaults', () => { it('GREEN: should accept minimal artifact (title + artifactType only)', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'User Model', artifactType: 'code', }, }); expect(result.artifactId).toBeDefined(); // Verify defaults were applied const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId, fields: ['*'] }); expect(artifact.title).toBe('User Model'); expect(artifact.artifactType).toBe('code'); expect(artifact.description).toBe(''); // default expect(artifact.slug).toBeDefined(); // auto-generated expect(artifact.status).toBe('draft'); }); }); it('should add a code artifact', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'User Service', description: 'Service for user management', artifactType: 'code', content: { language: 'typescript', sourceCode: 'export class UserService {}', filename: 'user-service.ts', }, }, }); expect(result.artifactId).toBeDefined(); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.title).toBe('User Service'); expect(artifact.artifactType).toBe('code'); expect(artifact.content.language).toBe('typescript'); expect(artifact.status).toBe('draft'); }); it('should add artifact with targets', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Database Migration', description: 'Add users table', artifactType: 'migration', content: { language: 'sql', sourceCode: 'CREATE TABLE users (id INT PRIMARY KEY);', }, targets: [ { path: 'migrations/001_users.sql', action: 'create', description: 'User table migration' }, { path: 'src/models/user.ts', action: 'create', description: 'User model' }, ], }, }); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.targets).toHaveLength(2); if (artifact.targets === undefined || artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(artifact.targets[0].action).toBe('create'); }); it('should add artifact with related entities', async () => { // Create a real phase first for valid relatedPhaseId const phase = await phaseService.addPhase({ planId, phase: { title: 'Implementation Phase', description: 'Test phase', }, }); // Sprint 5: Create real requirements for valid relatedRequirementIds const req1 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 1', description: 'Test', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); const req2 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 2', description: 'Test', priority: 'medium', category: 'technical', source: { type: 'user-request' }, }, }); const result = await service.addArtifact({ planId, artifact: { title: 'Config File', description: 'Application config', artifactType: 'config', content: { language: 'yaml', sourceCode: 'database:\n host: localhost', filename: 'config.yml', }, relatedPhaseId: phase.phaseId, relatedRequirementIds: [req1.requirementId, req2.requirementId], }, }); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.relatedPhaseId).toBe(phase.phaseId); expect(artifact.relatedRequirementIds).toEqual([req1.requirementId, req2.requirementId]); }); // BUG #12: Foreign key validation for relatedPhaseId reference describe('relatedPhaseId validation (BUG #12 - Sprint 4)', () => { it('RED: should reject non-existent phase ID in relatedPhaseId', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Artifact with fake phase', artifactType: 'code', relatedPhaseId: 'non-existent-phase-id', }, })).rejects.toThrow(/Phase.*non-existent-phase-id.*not found/i); }); it('GREEN: should accept valid phase ID in relatedPhaseId', async () => { // Create a real phase first const phase = await phaseService.addPhase({ planId, phase: { title: 'Real Phase', description: 'Test phase', }, }); // Should succeed with valid phase ID const result = await service.addArtifact({ planId, artifact: { title: 'Artifact with real phase', artifactType: 'code', relatedPhaseId: phase.phaseId, }, }); expect(result.artifactId).toBeDefined(); const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.relatedPhaseId).toBe(phase.phaseId); }); it('GREEN: should accept undefined relatedPhaseId (no validation needed)', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Artifact without phase', artifactType: 'code', }, }); expect(result.artifactId).toBeDefined(); }); }); // Sprint 5: BUG #11 - Foreign key validation for relatedRequirementIds describe('relatedRequirementIds validation (BUG #11 - Sprint 5)', () => { it('RED: should reject non-existent requirement ID in relatedRequirementIds', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Artifact with fake requirement', artifactType: 'code', relatedRequirementIds: ['non-existent-req-id'], }, })).rejects.toThrow(/Requirement.*non-existent-req-id.*not found/i); }); it('RED: should reject when any requirement ID in array is non-existent', async () => { // Create a real requirement first const req = await requirementService.addRequirement({ planId, requirement: { title: 'Real Requirement', description: 'Test requirement', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); // Should fail because one ID is invalid await expect(service.addArtifact({ planId, artifact: { title: 'Artifact with mixed requirements', artifactType: 'code', relatedRequirementIds: [req.requirementId, 'non-existent-req-id'], }, })).rejects.toThrow(/Requirement.*non-existent-req-id.*not found/i); }); it('GREEN: should accept valid requirement IDs in relatedRequirementIds', async () => { // Create real requirements first const req1 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 1', description: 'Test', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); const req2 = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement 2', description: 'Test', priority: 'medium', category: 'technical', source: { type: 'user-request' }, }, }); const result = await service.addArtifact({ planId, artifact: { title: 'Artifact with real requirements', artifactType: 'code', relatedRequirementIds: [req1.requirementId, req2.requirementId], }, }); expect(result.artifactId).toBeDefined(); const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.relatedRequirementIds).toEqual([req1.requirementId, req2.requirementId]); }); it('GREEN: should accept empty relatedRequirementIds array', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Artifact with empty requirements', artifactType: 'code', relatedRequirementIds: [], }, }); expect(result.artifactId).toBeDefined(); }); it('GREEN: should accept undefined relatedRequirementIds', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Artifact without requirements', artifactType: 'code', }, }); expect(result.artifactId).toBeDefined(); }); }); // Sprint 5: BUG #11 - Foreign key validation for relatedSolutionId describe('relatedSolutionId validation (BUG #11 - Sprint 5)', () => { it('RED: should reject non-existent solution ID in relatedSolutionId', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Artifact with fake solution', artifactType: 'code', relatedSolutionId: 'non-existent-solution-id', }, })).rejects.toThrow(/Solution.*non-existent-solution-id.*not found/i); }); it('GREEN: should accept valid solution ID in relatedSolutionId', async () => { // Create a real requirement first (solution needs to address a requirement) const req = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement for solution', description: 'Test', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); // Create a real solution const sol = await solutionService.proposeSolution({ planId, solution: { title: 'Real Solution', description: 'Test solution', addressing: [req.requirementId], }, }); const result = await service.addArtifact({ planId, artifact: { title: 'Artifact with real solution', artifactType: 'code', relatedSolutionId: sol.solutionId, }, }); expect(result.artifactId).toBeDefined(); const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.relatedSolutionId).toBe(sol.solutionId); }); it('GREEN: should accept undefined relatedSolutionId', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Artifact without solution', artifactType: 'code', }, }); expect(result.artifactId).toBeDefined(); }); }); }); describe('getArtifact', () => { it('should get artifact by id', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'js', sourceCode: 'const x = 1;' }, }, }); const result = await service.getArtifact({ planId, artifactId: added.artifactId, }); expect(result.artifact.id).toBe(added.artifactId); expect(result.artifact.title).toBe('Test Artifact'); }); it('should throw for non-existent artifact', async () => { await expect( service.getArtifact({ planId, artifactId: 'non-existent', }) ).rejects.toThrow('not found'); }); }); describe('updateArtifact', () => { it('should update artifact content', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Original', description: 'Original desc', artifactType: 'code', content: { language: 'ts', sourceCode: 'const x = 1;' }, }, }); await service.updateArtifact({ planId, artifactId: added.artifactId, updates: { title: 'Updated', content: { language: 'ts', sourceCode: 'const y = 2;' }, }, }); // Verify via getArtifact (use includeContent=true to get sourceCode) const { artifact } = await service.getArtifact({ planId, artifactId: added.artifactId, fields: ['*'], includeContent: true }); expect(artifact.title).toBe('Updated'); expect(artifact.content.sourceCode).toBe('const y = 2;'); expect(artifact.version).toBe(2); }); it('should update artifact status', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); await service.updateArtifact({ planId, artifactId: added.artifactId, updates: { status: 'reviewed' }, }); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: added.artifactId }); expect(artifact.status).toBe('reviewed'); }); it('should update artifact with codeRefs', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); await service.updateArtifact({ planId, artifactId: added.artifactId, updates: { codeRefs: ['src/updated-file.ts:50', 'tests/updated.test.ts:75'], }, }); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: added.artifactId }); expect(artifact.codeRefs).toHaveLength(2); if (artifact.codeRefs === undefined || artifact.codeRefs.length < 2) { throw new Error('CodeRefs should be defined with at least 2 elements'); } expect(artifact.codeRefs[0]).toBe('src/updated-file.ts:50'); expect(artifact.codeRefs[1]).toBe('tests/updated.test.ts:75'); }); it('should validate codeRefs on update', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); await expect( service.updateArtifact({ planId, artifactId: added.artifactId, updates: { codeRefs: ['no-line-number'], }, }) ).rejects.toThrow(/must be in format/i); }); // Sprint 5: BUG #11 - FK validation for relatedRequirementIds in updateArtifact describe('relatedRequirementIds validation in updateArtifact (BUG #11 - Sprint 5)', () => { let artifactId: string; beforeEach(async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', artifactType: 'code', }, }); artifactId = result.artifactId; }); it('RED: should reject non-existent requirement ID in updateArtifact', async () => { await expect(service.updateArtifact({ planId, artifactId, updates: { relatedRequirementIds: ['non-existent-req-id'], }, })).rejects.toThrow(/Requirement.*non-existent-req-id.*not found/i); }); it('GREEN: should accept valid requirement IDs in updateArtifact', async () => { const req = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement for update', description: 'Test', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); const result = await service.updateArtifact({ planId, artifactId, updates: { relatedRequirementIds: [req.requirementId], }, }); expect(result.success).toBe(true); const { artifact } = await service.getArtifact({ planId, artifactId }); expect(artifact.relatedRequirementIds).toEqual([req.requirementId]); }); }); // Sprint 5: BUG #11 - FK validation for relatedSolutionId in updateArtifact describe('relatedSolutionId validation in updateArtifact (BUG #11 - Sprint 5)', () => { let artifactId: string; beforeEach(async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', artifactType: 'code', }, }); artifactId = result.artifactId; }); it('RED: should reject non-existent solution ID in updateArtifact', async () => { await expect(service.updateArtifact({ planId, artifactId, updates: { relatedSolutionId: 'non-existent-solution-id', }, })).rejects.toThrow(/Solution.*non-existent-solution-id.*not found/i); }); it('GREEN: should accept valid solution ID in updateArtifact', async () => { const req = await requirementService.addRequirement({ planId, requirement: { title: 'Requirement for solution', description: 'Test', priority: 'high', category: 'functional', source: { type: 'user-request' }, }, }); const sol = await solutionService.proposeSolution({ planId, solution: { title: 'Solution for update', description: 'Test solution', addressing: [req.requirementId], }, }); const result = await service.updateArtifact({ planId, artifactId, updates: { relatedSolutionId: sol.solutionId, }, }); expect(result.success).toBe(true); const { artifact } = await service.getArtifact({ planId, artifactId }); expect(artifact.relatedSolutionId).toBe(sol.solutionId); }); }); }); describe('listArtifacts', () => { beforeEach(async () => { await service.addArtifact({ planId, artifact: { title: 'Code Artifact', description: 'Code', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); await service.addArtifact({ planId, artifact: { title: 'Migration Artifact', description: 'SQL', artifactType: 'migration', content: { language: 'sql', sourceCode: '' }, }, }); await service.addArtifact({ planId, artifact: { title: 'Config Artifact', description: 'YAML', artifactType: 'config', content: { language: 'yaml', sourceCode: '' }, }, }); }); it('should list all artifacts', async () => { const result = await service.listArtifacts({ planId }); expect(result.artifacts).toHaveLength(3); }); it('should filter by artifactType', async () => { const result = await service.listArtifacts({ planId, filters: { artifactType: 'code' }, }); expect(result.artifacts).toHaveLength(1); expect(result.artifacts[0].title).toBe('Code Artifact'); }); }); describe('deleteArtifact', () => { it('should delete artifact', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'To Delete', description: 'Delete me', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const result = await service.deleteArtifact({ planId, artifactId: added.artifactId, }); expect(result.success).toBe(true); const list = await service.listArtifacts({ planId }); expect(list.artifacts).toHaveLength(0); }); it('should throw for non-existent artifact', async () => { await expect( service.deleteArtifact({ planId, artifactId: 'non-existent', }) ).rejects.toThrow('not found'); }); }); describe('edge cases', () => { it('should add artifact without content field (documentation with targets only)', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Critical Files to Read', description: 'Key files for implementation', artifactType: 'documentation', targets: [ { path: 'src/services/user.ts', action: 'modify', description: 'User service' }, { path: 'src/models/user.ts', action: 'modify', description: 'User model' }, ], }, }); expect(result.artifactId).toBeDefined(); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.title).toBe('Critical Files to Read'); expect(artifact.artifactType).toBe('documentation'); expect(artifact.targets).toHaveLength(2); expect(artifact.content).toEqual({}); }); it('should throw for non-existent planId on addArtifact', async () => { await expect( service.addArtifact({ planId: 'non-existent-plan', artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }) ).rejects.toThrow('Plan not found'); }); it('should throw for non-existent planId on getArtifact', async () => { await expect( service.getArtifact({ planId: 'non-existent-plan', artifactId: 'any', }) ).rejects.toThrow('Plan not found'); }); it('should throw for non-existent planId on updateArtifact', async () => { await expect( service.updateArtifact({ planId: 'non-existent-plan', artifactId: 'any', updates: { title: 'New' }, }) ).rejects.toThrow('Plan not found'); }); it('should throw for non-existent planId on listArtifacts', async () => { await expect( service.listArtifacts({ planId: 'non-existent-plan', }) ).rejects.toThrow('Plan not found'); }); it('should throw for non-existent planId on deleteArtifact', async () => { await expect( service.deleteArtifact({ planId: 'non-existent-plan', artifactId: 'any', }) ).rejects.toThrow('Plan not found'); }); it('should throw for invalid artifactType', async () => { await expect( service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'invalid' as unknown as 'code', content: { language: 'ts', sourceCode: '' }, }, }) ).rejects.toThrow(/artifactType/i); }); it('should throw for invalid targets action', async () => { await expect( service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'file.ts', action: 'invalid' as unknown as 'create' }], }, }) ).rejects.toThrow(/action/i); }); it('should add artifact with codeRefs', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Implementation artifact', description: 'Artifact with code references', artifactType: 'code', content: { language: 'typescript', sourceCode: 'export class MyClass {}' }, codeRefs: [ 'src/services/my-service.ts:42', 'tests/my-service.test.ts:100', ], }, }); // Verify via getArtifact const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.codeRefs).toHaveLength(2); if (artifact.codeRefs === undefined || artifact.codeRefs.length < 2) { throw new Error('CodeRefs should be defined with at least 2 elements'); } expect(artifact.codeRefs[0]).toBe('src/services/my-service.ts:42'); expect(artifact.codeRefs[1]).toBe('tests/my-service.test.ts:100'); }); it('should validate codeRefs format in addArtifact', async () => { await expect( service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, codeRefs: ['invalid-no-line-number'], }, }) ).rejects.toThrow(/must be in format/i); }); it('should validate codeRefs line number in addArtifact', async () => { await expect( service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, codeRefs: ['src/file.ts:0'], }, }) ).rejects.toThrow(/line number must be a positive integer/i); }); }); describe('slug functionality', () => { describe('CYCLE 0: Slug format validation (Bug #13 - Sprint 6)', () => { it('RED: should reject slug with spaces', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'my slug', }, })).rejects.toThrow(/must be lowercase alphanumeric with dashes/i); }); it('RED: should reject slug with uppercase letters', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'MySlug', }, })).rejects.toThrow(/must be lowercase alphanumeric with dashes/i); }); it('RED: should reject slug with special characters', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'my@slug!', }, })).rejects.toThrow(/must be lowercase alphanumeric with dashes/i); }); it('RED: should reject slug with leading dash', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: '-myslug', }, })).rejects.toThrow(/cannot start or end with a dash/i); }); it('RED: should reject slug with trailing dash', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'myslug-', }, })).rejects.toThrow(/cannot start or end with a dash/i); }); it('RED: should reject slug with consecutive dashes', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'my--slug', }, })).rejects.toThrow(/cannot contain consecutive dashes/i); }); it('RED: should reject slug exceeding max length (100)', async () => { const longSlug = 'a'.repeat(101); await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: longSlug, }, })).rejects.toThrow(/must not exceed 100 characters/i); }); it('RED: should reject empty slug', async () => { await expect(service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: '', }, })).rejects.toThrow(/must be a non-empty string/i); }); it('GREEN: should accept valid slug format', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test', artifactType: 'code', slug: 'my-valid-slug-123', }, }); expect(result.artifactId).toBeDefined(); const { artifact } = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(artifact.slug).toBe('my-valid-slug-123'); }); }); describe('CYCLE 1: Basic slug storage and retrieval', () => { it('should save and retrieve artifact with explicit slug', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'User Service', description: 'Service implementation', artifactType: 'code', content: { language: 'typescript', sourceCode: 'export class UserService {}' }, slug: 'my-artifact', }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId, }); expect(retrieved.artifact.slug).toBe('my-artifact'); }); }); describe('CYCLE 2: Auto-generate slug from title', () => { it('should auto-generate slug when not provided', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'User Service Implementation', description: 'Service for user management', artifactType: 'code', content: { language: 'typescript', sourceCode: 'export class UserService {}' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId, }); expect(retrieved.artifact.slug).toBe('user-service-implementation'); }); }); describe('CYCLE 3: Slug normalization edge cases', () => { it('should remove special characters', async () => { const result = await service.addArtifact({ planId, artifact: { title: "User's Service!!!", description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe('users-service'); }); it('should collapse multiple spaces', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Multiple Spaces', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe('multiple-spaces'); }); it('should collapse multiple dashes', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test--Double--Dashes', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe('test-double-dashes'); }); it('should handle numbers correctly', async () => { const result = await service.addArtifact({ planId, artifact: { title: '123 Numbers 456', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe('123-numbers-456'); }); it('should handle Unicode by removing non-ASCII', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Unicode Привет', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe('unicode'); }); it('should use fallback for empty results (only special chars)', async () => { const result = await service.addArtifact({ planId, artifact: { title: '!!!', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toBe(`artifact-${result.artifactId}`); }); it('should enforce max length of 100 characters', async () => { const longTitle = 'A'.repeat(150); const result = await service.addArtifact({ planId, artifact: { title: longTitle, description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.slug).toHaveLength(100); expect(retrieved.artifact.slug).toBe('a'.repeat(100)); }); }); describe('CYCLE 4: Slug uniqueness validation', () => { it('should throw error for duplicate explicit slug', async () => { await service.addArtifact({ planId, artifact: { title: 'First Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, slug: 'duplicate-slug', }, }); await expect( service.addArtifact({ planId, artifact: { title: 'Second Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, slug: 'duplicate-slug', }, }) ).rejects.toThrow(/slug.*duplicate-slug.*already exists/i); }); it('should throw error for duplicate auto-generated slug', async () => { await service.addArtifact({ planId, artifact: { title: 'Same Title', description: 'First', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); await expect( service.addArtifact({ planId, artifact: { title: 'Same Title', description: 'Second', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }) ).rejects.toThrow(/slug.*same-title.*already exists/i); }); }); }); describe('ArtifactTarget support (Phase 2.3)', () => { describe('RED: addArtifact with targets field', () => { it('RED: should accept targets with basic path and action', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'src/file.ts', action: 'create' }], }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); expect(retrieved.artifact.targets).toBeDefined(); expect(retrieved.artifact.targets).toHaveLength(1); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].path).toBe('src/file.ts'); expect(retrieved.artifact.targets[0].action).toBe('create'); }); it('RED: should accept targets with lineNumber', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'src/file.ts', action: 'modify', lineNumber: 42 }], }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].lineNumber).toBe(42); }); it('RED: should accept targets with lineNumber and lineEnd', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'src/file.ts', action: 'modify', lineNumber: 10, lineEnd: 20 }], }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].lineNumber).toBe(10); expect(retrieved.artifact.targets[0].lineEnd).toBe(20); }); it('RED: should accept targets with searchPattern', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'src/file.ts', action: 'modify', searchPattern: 'function.*test' }], }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].searchPattern).toBe('function.*test'); }); it('RED: should accept targets with description', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'src/file.ts', action: 'create', description: 'Main source file' }], }, }); const retrieved = await service.getArtifact({ planId, artifactId: result.artifactId }); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].description).toBe('Main source file'); }); }); describe('RED: updateArtifact to modify targets', () => { it('RED: should update targets field', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, targets: [{ path: 'old.ts', action: 'create' }], }, }); await service.updateArtifact({ planId, artifactId: added.artifactId, updates: { targets: [{ path: 'new.ts', action: 'modify', lineNumber: 10 }] }, }); const retrieved = await service.getArtifact({ planId, artifactId: added.artifactId }); expect(retrieved.artifact.targets).toHaveLength(1); if (!retrieved.artifact.targets || retrieved.artifact.targets.length === 0) { throw new Error('Targets should be defined and not empty'); } expect(retrieved.artifact.targets[0].path).toBe('new.ts'); expect(retrieved.artifact.targets[0].lineNumber).toBe(10); }); }); describe('RED: targets validation', () => { it('RED: should validate targets using validateTargets', async () => { await expect( service.addArtifact({ planId, artifact: { title: 'Invalid', description: 'Test', artifactType: 'code', content: {}, targets: [{ path: '', action: 'create' }], // Invalid: empty path }, }) ).rejects.toThrow(/path must be a non-empty string/); }); }); }); describe('minimal return values (Sprint 6)', () => { describe('addArtifact should return only artifactId', () => { it('should not include full artifact object in result', async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Test Artifact', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: 'console.log("test")' }, }, }); expect(result.artifactId).toBeDefined(); expect(result).not.toHaveProperty('artifact'); }); }); describe('updateArtifact should return only success and artifactId', () => { it('should not include full artifact object in result', async () => { const added = await service.addArtifact({ planId, artifact: { title: 'Test', description: 'Test', artifactType: 'code', content: { language: 'ts', sourceCode: '' }, }, }); const result = await service.updateArtifact({ planId, artifactId: added.artifactId, updates: { title: 'Updated' }, }); expect(result.success).toBe(true); expect(result).not.toHaveProperty('artifact'); }); }); describe('BUG #18: Title validation in updateArtifact (TDD - RED phase)', () => { let artifactId: string; beforeEach(async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Original Title', artifactType: 'code', }, }); artifactId = result.artifactId; }); it('RED: should reject empty title', async () => { await expect(service.updateArtifact({ planId, artifactId, updates: { title: '' }, })).rejects.toThrow('title must be a non-empty string'); }); it('RED: should reject whitespace-only title', async () => { await expect(service.updateArtifact({ planId, artifactId, updates: { title: ' ' }, })).rejects.toThrow('title must be a non-empty string'); }); it('GREEN: should allow valid title update', async () => { const result = await service.updateArtifact({ planId, artifactId, updates: { title: 'New Valid Title' }, }); expect(result.success).toBe(true); const updated = await service.getArtifact({ planId, artifactId, }); expect(updated.artifact.title).toBe('New Valid Title'); }); }); }); describe('fields parameter support', () => { let artId: string; let testPhaseId: string; beforeEach(async () => { // Create a real phase for relatedPhaseId const phase = await phaseService.addPhase({ planId, phase: { title: 'Test Phase for Fields' }, }); testPhaseId = phase.phaseId; const result = await service.addArtifact({ planId, artifact: { title: 'Complete Artifact', description: 'Full artifact description', slug: 'complete-artifact', artifactType: 'code', content: { language: 'typescript', sourceCode: 'const x = 1;\nconst y = 2;\n// ... 1000 lines of code', filename: 'test.ts', }, targets: [ { path: 'src/test.ts', action: 'create', lineNumber: 42, description: 'Main file' }, ], relatedPhaseId: testPhaseId, codeRefs: ['src/main.ts:10'], }, }); artId = result.artifactId; }); describe('getArtifact with fields', () => { it('should return only minimal fields when fields=["id","title"]', async () => { const result = await service.getArtifact({ planId, artifactId: artId, fields: ['id', 'title'], }); const art = result.artifact as unknown as Record<string, unknown>; expect(art.id).toBe(artId); expect(art.title).toBe('Complete Artifact'); expect(art.description).toBeUndefined(); expect(art.content).toBeUndefined(); }); it('should return summary fields by default WITHOUT heavy sourceCode (Lazy-Load)', async () => { const result = await service.getArtifact({ planId, artifactId: artId, }); const art = result.artifact; expect(art.id).toBeDefined(); expect(art.title).toBeDefined(); expect(art.slug).toBeDefined(); expect(art.artifactType).toBeDefined(); expect(art.status).toBeDefined(); // Lazy-Load: sourceCode NOT included by default (use fields=['*'] to get it) expect(art.content.sourceCode).toBeUndefined(); expect(art.targets).toBeDefined(); expect(art.codeRefs).toEqual(['src/main.ts:10']); }); it('should return all fields when fields=["*"] and includeContent=true', async () => { const result = await service.getArtifact({ planId, artifactId: artId, fields: ['*'], includeContent: true, // Required for sourceCode (Variant B: explicit control) }); const art = result.artifact; expect(art.content.sourceCode).toContain('const x = 1'); expect(art.targets).toBeDefined(); expect(art.codeRefs).toEqual(['src/main.ts:10']); }); }); describe('listArtifacts with fields', () => { it('should return summary fields by default WITHOUT sourceCode', async () => { const result = await service.listArtifacts({ planId, }); expect(result.artifacts.length).toBeGreaterThan(0); const art = result.artifacts[0]; expect(art.id).toBeDefined(); expect(art.title).toBeDefined(); // sourceCode should NEVER be in list even with full mode (too heavy) const content = art.content as unknown as Record<string, unknown> | undefined; expect(content?.sourceCode).toBeUndefined(); }); it('should return minimal fields when specified', async () => { const result = await service.listArtifacts({ planId, fields: ['id', 'title', 'artifactType'], }); const art = result.artifacts[0] as unknown as Record<string, unknown>; expect(art.id).toBeDefined(); expect(art.title).toBeDefined(); expect(art.artifactType).toBeDefined(); expect(art.description).toBeUndefined(); }); }); }); // Sprint 3 RED: includeContent parameter for explicit Lazy-Load control describe('Sprint 3 RED: includeContent parameter for Lazy-Load', () => { let artId: string; beforeEach(async () => { const result = await service.addArtifact({ planId, artifact: { title: 'Heavy Artifact', description: 'Artifact with large sourceCode', artifactType: 'code', content: { language: 'typescript', sourceCode: '// Large source code (50KB)\n' + 'x'.repeat(50000), filename: 'heavy.ts', }, }, }); artId = result.artifactId; }); describe('getArtifact with includeContent', () => { it('RED: should NOT include sourceCode by default (includeContent=false implicit)', async () => { const result = await service.getArtifact({ planId, artifactId: artId, }); const art = result.artifact; expect(art.content).toBeDefined(); expect(art.content.language).toBe('typescript'); expect(art.content.filename).toBe('heavy.ts'); // RED: sourceCode should be excluded by default (Lazy-Load) expect(art.content.sourceCode).toBeUndefined(); }); it('RED: should NOT include sourceCode when includeContent=false', async () => { const result = await service.getArtifact({ planId, artifactId: artId, includeContent: false, }); expect(result.artifact.content.sourceCode).toBeUndefined(); }); it('RED: should include sourceCode when includeContent=true', async () => { const result = await service.getArtifact({ planId, artifactId: artId, includeContent: true, }); const art = result.artifact; expect(art.content.sourceCode).toBeDefined(); expect(art.content.sourceCode).toContain('Large source code'); if (art.content.sourceCode === undefined) throw new Error('SourceCode should be defined'); expect(art.content.sourceCode.length).toBeGreaterThan(50000); }); it('RED: includeContent=true should work with fields parameter', async () => { const result = await service.getArtifact({ planId, artifactId: artId, fields: ['id', 'title', 'content'], includeContent: true, }); const art = result.artifact as unknown as Record<string, unknown>; expect(art.id).toBe(artId); expect(art.title).toBe('Heavy Artifact'); expect(art.description).toBeUndefined(); // not in fields const content = art.content as { sourceCode?: string; language?: string }; expect(content.sourceCode).toBeDefined(); }); it('RED: includeContent=false should override fields=["*"]', async () => { const result = await service.getArtifact({ planId, artifactId: artId, fields: ['*'], includeContent: false, }); const art = result.artifact; // All fields should be present expect(art.title).toBe('Heavy Artifact'); expect(art.description).toBe('Artifact with large sourceCode'); // But sourceCode should be excluded due to includeContent=false expect(art.content.sourceCode).toBeUndefined(); }); it('PREVENTIVE: includeContent=true should NOT add content when explicitly excluded from fields', async () => { const result = await service.getArtifact({ planId, artifactId: artId, fields: ['id', 'title', 'artifactType'], // content NOT in fields includeContent: true, // Should NOT override explicit field selection }); const art = result.artifact as unknown as Record<string, unknown>; // Should only include requested fields expect(art.id).toBe(artId); expect(art.title).toBe('Heavy Artifact'); expect(art.artifactType).toBe('code'); // PREVENTIVE CHECK: content should NOT appear because it was explicitly excluded from fields // This test prevents regression - ensures includeContent respects fields parameter // (Different from filterPhase bug: filterArtifact has different architecture) expect(art.content).toBeUndefined(); // Other fields should also be undefined expect(art.description).toBeUndefined(); expect(art.slug).toBeUndefined(); }); }); describe('listArtifacts with includeContent', () => { it('RED: should NOT include sourceCode in list by default', async () => { const result = await service.listArtifacts({ planId, }); const art = result.artifacts.find((a) => a.id === artId); expect(art).toBeDefined(); if (art === undefined) throw new Error('Art should be defined'); expect(art.content.sourceCode).toBeUndefined(); }); it('RED: should IGNORE includeContent=true in list (security: never return sourceCode in list)', async () => { const result = await service.listArtifacts({ planId, includeContent: true, }); const art = result.artifacts.find((a) => a.id === artId); // Even with includeContent=true, list should NEVER return sourceCode if (art === undefined) throw new Error('Art should be defined'); expect(art.content.sourceCode).toBeUndefined(); }); }); describe('Edge cases for includeContent', () => { it('RED: should handle artifact without sourceCode + includeContent=true', async () => { const noCodeResult = await service.addArtifact({ planId, artifact: { title: 'Config File', description: 'No source code', artifactType: 'config', content: { filename: 'config.json', }, }, }); const result = await service.getArtifact({ planId, artifactId: noCodeResult.artifactId, includeContent: true, }); // Should not crash, just return undefined sourceCode expect(result.artifact.content.sourceCode).toBeUndefined(); }); it('RED: should measure payload size difference (with vs without sourceCode)', async () => { const withoutCode = await service.getArtifact({ planId, artifactId: artId, includeContent: false, }); const withCode = await service.getArtifact({ planId, artifactId: artId, includeContent: true, }); const withoutSize = JSON.stringify(withoutCode.artifact).length; const withSize = JSON.stringify(withCode.artifact).length; // Verify significant size difference (100x as per sprint requirements) expect(withSize).toBeGreaterThan(withoutSize * 10); // At least 10x expect(withoutSize).toBeLessThan(2000); // Less than 2KB without sourceCode expect(withSize).toBeGreaterThan(50000); // Greater than 50KB with sourceCode }); }); }); });

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