import { DataSource } from 'typeorm'; import { ProjectTemplateService } from '../../../src/services/project/project-template.service'; import { ProjectTemplateEntity, ProjectTemplateSchema } from '../../../src/entities/project-template.entity'; import { ProjectEntity } from '../../../src/entities/project.entity'; describe('ProjectTemplateService', () => { let dataSource: DataSource; let templateService: ProjectTemplateService; let mockTemplateRepo: any; let mockProjectRepo: any; beforeEach(() => { // Mock repository methods mockTemplateRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), find: jest.fn(), delete: jest.fn(), count: jest.fn(), update: jest.fn(), createQueryBuilder: jest.fn(() => ({ where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), getMany: jest.fn().mockResolvedValue([]), getOne: jest.fn().mockResolvedValue(null) })) }; mockProjectRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), find: jest.fn() }; // Mock DataSource dataSource = { getRepository: jest.fn((entity) => { if (entity === ProjectTemplateEntity) { return mockTemplateRepo; } if (entity === ProjectEntity) { return mockProjectRepo; } return {}; }), transaction: jest.fn(async (callback) => { const mockManager = { getRepository: jest.fn((entity) => { if (entity === ProjectTemplateEntity) { return mockTemplateRepo; } if (entity === ProjectEntity) { return mockProjectRepo; } return {}; }) }; return await callback(mockManager); }) } as unknown as DataSource; templateService = new ProjectTemplateService(dataSource); }); describe('createTemplate', () => { it('should create a new template successfully', async () => { const templateSchema: ProjectTemplateSchema = { name: 'Test Project', description: 'Test Description', status: 'active', priority: 'high', durationDays: 30, tasks: [ { title: 'Task 1', description: 'Task 1 description', priority: 'high' } ] }; const createDto = { name: 'Test Template', description: 'A test template', category: 'project' as const, schema: templateSchema, tags: ['test', 'demo'] }; const createdTemplate = { id: 'template-1', ...createDto, status: 'draft', usageStats: { useCount: 0 } }; mockTemplateRepo.findOne.mockResolvedValue(null); mockTemplateRepo.create.mockReturnValue(createdTemplate); mockTemplateRepo.save.mockResolvedValue(createdTemplate); const result = await templateService.createTemplate(createDto); expect(result).toEqual(createdTemplate); expect(mockTemplateRepo.findOne).toHaveBeenCalledWith({ where: { name: 'Test Template' } }); expect(mockTemplateRepo.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Test Template', schema: templateSchema })); }); it('should throw error if template name already exists', async () => { mockTemplateRepo.findOne.mockResolvedValue({ id: 'existing', name: 'Test Template' }); await expect( templateService.createTemplate({ name: 'Test Template', schema: { name: 'Test' }, category: 'project' }) ).rejects.toThrow('Template with name "Test Template" already exists'); }); it('should validate parent template if specified', async () => { const createDto = { name: 'Child Template', schema: { name: 'Child' }, parentTemplateId: 'parent-1', category: 'project' as const }; mockTemplateRepo.findOne .mockResolvedValueOnce(null) // Name check .mockResolvedValueOnce(null); // Parent check await expect(templateService.createTemplate(createDto)) .rejects.toThrow('Parent template with ID parent-1 not found'); }); }); describe('updateTemplate', () => { it('should update template successfully', async () => { const existingTemplate = { id: 'template-1', name: 'Original Template', description: 'Original description', schema: { name: 'Original' }, category: 'project' }; const updateDto = { id: 'template-1', description: 'Updated description', schema: { name: 'Updated' } }; mockTemplateRepo.findOne.mockResolvedValue(existingTemplate); mockTemplateRepo.save.mockResolvedValue({ ...existingTemplate, ...updateDto }); const result = await templateService.updateTemplate(updateDto); expect(result.description).toBe('Updated description'); expect(mockTemplateRepo.save).toHaveBeenCalled(); }); it('should throw error if template not found', async () => { mockTemplateRepo.findOne.mockResolvedValue(null); await expect( templateService.updateTemplate({ id: 'non-existent', description: 'Updated' }) ).rejects.toThrow('Template with ID non-existent not found'); }); it('should check name uniqueness on update', async () => { const existingTemplate = { id: 'template-1', name: 'Original', schema: { name: 'Original' } }; mockTemplateRepo.findOne .mockResolvedValueOnce(existingTemplate) // Find template to update .mockResolvedValueOnce({ id: 'template-2', name: 'NewName' }); // Name already exists await expect( templateService.updateTemplate({ id: 'template-1', name: 'NewName' }) ).rejects.toThrow('Template with name "NewName" already exists'); }); }); describe('deleteTemplate', () => { it('should delete template successfully', async () => { mockTemplateRepo.findOne.mockResolvedValue({ id: 'template-1', name: 'Test Template' }); mockTemplateRepo.count.mockResolvedValue(0); mockTemplateRepo.delete.mockResolvedValue({ affected: 1 }); const result = await templateService.deleteTemplate('template-1'); expect(result).toBe(true); expect(mockTemplateRepo.delete).toHaveBeenCalledWith('template-1'); }); it('should prevent deletion if template has children', async () => { mockTemplateRepo.findOne.mockResolvedValue({ id: 'template-1', name: 'Parent Template' }); mockTemplateRepo.count.mockResolvedValue(2); // Has child templates await expect(templateService.deleteTemplate('template-1')) .rejects.toThrow('Cannot delete template that is being used as a parent by other templates'); }); }); describe('applyTemplate', () => { it('should apply template to create project successfully', async () => { const template = { id: 'template-1', name: 'Test Template', status: 'published', schema: { name: 'Template Project', description: 'From template', status: 'active', priority: 'high', durationDays: 30, structure: { children: [ { name: 'Phase 1', durationDays: 10 }, { name: 'Phase 2', durationDays: 20 } ] } } as ProjectTemplateSchema, usageStats: { useCount: 0 } }; const createdProject = { id: 'project-1', name: 'New Project', description: 'From template', path: '0001' }; // Mock template retrieval jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); // Mock ProjectTreeService.insertNode const projectTreeService = (templateService as any).projectTreeService; jest.spyOn(projectTreeService, 'insertNode').mockResolvedValue(createdProject); // Mock template repository update for usage stats mockTemplateRepo.update.mockResolvedValue({ affected: 1 }); const result = await templateService.applyTemplate('template-1', { projectName: 'New Project', startDate: new Date('2025-01-01') }); expect(result.success).toBe(true); expect(result.projectId).toBe('project-1'); expect(result.createdProjects).toHaveLength(3); // 1 root + 2 children }); it('should handle deprecated templates', async () => { const template = { id: 'template-1', status: 'deprecated', schema: { name: 'Deprecated' } }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); const result = await templateService.applyTemplate('template-1', { projectName: 'New Project' }); expect(result.success).toBe(false); expect(result.errors).toContain('Cannot apply a deprecated template'); }); it('should validate variables if validation schema exists', async () => { const template = { id: 'template-1', status: 'published', schema: { name: 'Template' }, validationSchema: { type: 'object', properties: { projectType: { type: 'string', enum: ['web', 'mobile'] } }, required: ['projectType'] }, usageStats: { useCount: 0 } }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); const result = await templateService.applyTemplate('template-1', { projectName: 'New Project', variables: { invalidField: 'value' } // Missing required field }); expect(result.success).toBe(false); expect(result.errors).toBeDefined(); }); }); describe('validateTemplate', () => { it('should validate template with invalid task dependencies', async () => { const template = { id: 'template-1', schema: { name: 'Valid Template', durationDays: 30, tasks: [ { title: 'Task 1', dependencies: ['999'] }, // Non-existent dependency { title: 'Task 2' } ], customFields: [ { name: 'projectType', type: 'select', label: 'Project Type', options: [ { value: 'web', label: 'Web' }, { value: 'mobile', label: 'Mobile' } ] } ] } as ProjectTemplateSchema }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); const result = await templateService.validateTemplate('template-1'); expect(result.valid).toBe(false); expect(result.errors).toContain('Task 0 has invalid dependency: 999'); }); it('should detect invalid regex patterns in custom fields', async () => { const template = { id: 'template-1', schema: { name: 'Template', customFields: [ { name: 'code', type: 'text', label: 'Code', validation: { pattern: '[invalid regex' } } ] } as ProjectTemplateSchema }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); const result = await templateService.validateTemplate('template-1'); expect(result.valid).toBe(false); expect(result.errors).toContain('Custom field "code" has invalid regex pattern'); }); }); describe('searchTemplates', () => { it('should search templates with criteria', async () => { const templates = [ { id: 'template-1', name: 'Web Template', category: 'project', status: 'published', tags: ['web', 'frontend'] }, { id: 'template-2', name: 'Mobile Template', category: 'project', status: 'published', tags: ['mobile', 'ios'] } ]; const mockQueryBuilder = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(templates) }; mockTemplateRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await templateService.searchTemplates({ category: 'project', status: 'published', searchTerm: 'Template' }); expect(result).toEqual(templates); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( 'template.category = :category', { category: 'project' } ); }); }); describe('cloneTemplate', () => { it('should clone template successfully', async () => { const sourceTemplate = { id: 'template-1', name: 'Original', description: 'Original template', category: 'project', schema: { name: 'Original Schema' }, tags: ['original'] }; const clonedTemplate = { id: 'template-2', name: 'Cloned Template', description: 'Cloned from Original', category: 'project', schema: { name: 'Original Schema' }, tags: ['original', 'cloned'] }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(sourceTemplate as ProjectTemplateEntity); jest.spyOn(templateService, 'createTemplate').mockResolvedValue(clonedTemplate as ProjectTemplateEntity); const result = await templateService.cloneTemplate('template-1', 'Cloned Template'); expect(result).toEqual(clonedTemplate); expect(templateService.createTemplate).toHaveBeenCalledWith( expect.objectContaining({ name: 'Cloned Template', tags: ['original', 'cloned'] }) ); }); }); describe('getTemplateStatistics', () => { it('should return template statistics', async () => { mockTemplateRepo.count.mockResolvedValue(10); const mockQueryBuilder = { select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), getRawMany: jest.fn() .mockResolvedValueOnce([ { category: 'project', count: '5' }, { category: 'workflow', count: '3' } ]) .mockResolvedValueOnce([ { status: 'published', count: '6' }, { status: 'draft', count: '4' } ]), getMany: jest.fn() .mockResolvedValueOnce([]) // Most used .mockResolvedValueOnce([]) // Recently created }; mockTemplateRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await templateService.getTemplateStatistics(); expect(result.totalTemplates).toBe(10); expect(result.byCategory).toEqual({ project: 5, workflow: 3 }); expect(result.byStatus).toEqual({ published: 6, draft: 4 }); }); }); describe('Template Schema Validation', () => { it('should validate hierarchical structure', async () => { const schema: ProjectTemplateSchema = { name: 'Root', structure: { children: [ { name: 'Child 1', children: [ { name: 'Grandchild 1' }, { name: 'Grandchild 2' } ] }, { name: 'Child 2', durationDays: -5 // Invalid negative duration } ] } }; const createDto = { name: 'Invalid Template', schema, category: 'project' as const }; mockTemplateRepo.findOne.mockResolvedValue(null); await expect(templateService.createTemplate(createDto)) .rejects.toThrow('Template schema validation failed'); }); it('should validate dependencies', async () => { const schema: ProjectTemplateSchema = { name: 'Template', dependencies: [ { from: 'project1', to: 'project1', // Self-reference type: 'blocks' } ] }; const createDto = { name: 'Dependency Template', schema, category: 'project' as const }; mockTemplateRepo.findOne.mockResolvedValue(null); await expect(templateService.createTemplate(createDto)) .rejects.toThrow('Template schema validation failed: Dependency cannot reference itself'); }); }); describe('Variable Substitution', () => { it('should substitute variables in template', async () => { const template = { id: 'template-1', status: 'published', schema: { name: 'Project {{projectName}}', description: 'A {{projectType}} project for {{client}}', metadata: { owner: '{{owner}}', budget: '{{budget}}' } } as ProjectTemplateSchema, usageStats: { useCount: 0 } }; const variables = { projectName: 'Alpha', projectType: 'Web', client: 'ACME Corp', owner: 'John Doe', budget: '50000' }; jest.spyOn(templateService, 'getTemplate').mockResolvedValue(template as ProjectTemplateEntity); // Mock project creation const createdProject = { id: 'project-1', name: 'Project Alpha', description: 'A Web project for ACME Corp', path: '0001', metadata: { owner: 'John Doe', budget: '50000', templateId: 'template-1', templateName: 'Test Template', appliedAt: new Date() } }; // Mock ProjectTreeService.insertNode const projectTreeService = (templateService as any).projectTreeService; jest.spyOn(projectTreeService, 'insertNode').mockResolvedValue(createdProject); // Mock template repository update for usage stats mockTemplateRepo.update.mockResolvedValue({ affected: 1 }); const result = await templateService.applyTemplate('template-1', { projectName: 'Project Alpha', variables }); expect(result.success).toBe(true); expect(projectTreeService.insertNode).toHaveBeenCalledWith( expect.objectContaining({ name: 'Project Alpha', description: 'A Web project for ACME Corp' }), undefined ); }); }); });