directus-task-management/tests/services/project/project-template.service.te...

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
);
});
});
});