636 lines
19 KiB
TypeScript
636 lines
19 KiB
TypeScript
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
|
|
);
|
|
});
|
|
});
|
|
}); |