589 lines
20 KiB
TypeScript
589 lines
20 KiB
TypeScript
import {beforeEach, describe, expect, it} from 'vitest';
|
|
import {TemplateType} from '@plunk/db';
|
|
import {TemplateService} from '../TemplateService';
|
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
|
|
|
describe('TemplateService', () => {
|
|
let projectId: string;
|
|
const prisma = getPrismaClient();
|
|
|
|
beforeEach(async () => {
|
|
const {project} = await factories.createUserWithProject();
|
|
projectId = project.id;
|
|
});
|
|
|
|
// ========================================
|
|
// CRUD OPERATIONS
|
|
// ========================================
|
|
describe('create', () => {
|
|
it('should create a template with all fields', async () => {
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Welcome Email',
|
|
description: 'Sent to new users',
|
|
subject: 'Welcome to {{company}}!',
|
|
body: '<h1>Hello {{firstName}}</h1>',
|
|
from: 'hello@example.com',
|
|
fromName: 'Company Team',
|
|
replyTo: 'support@example.com',
|
|
type: TemplateType.TRANSACTIONAL,
|
|
});
|
|
|
|
expect(template.name).toBe('Welcome Email');
|
|
expect(template.description).toBe('Sent to new users');
|
|
expect(template.subject).toBe('Welcome to {{company}}!');
|
|
expect(template.body).toBe('<h1>Hello {{firstName}}</h1>');
|
|
expect(template.from).toBe('hello@example.com');
|
|
expect(template.fromName).toBe('Company Team');
|
|
expect(template.replyTo).toBe('support@example.com');
|
|
expect(template.type).toBe(TemplateType.TRANSACTIONAL);
|
|
expect(template.projectId).toBe(projectId);
|
|
});
|
|
|
|
it('should create template with minimal required fields', async () => {
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Basic Template',
|
|
subject: 'Test',
|
|
body: 'Test body',
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
expect(template.name).toBe('Basic Template');
|
|
expect(template.type).toBe(TemplateType.MARKETING); // Default type
|
|
expect(template.description).toBeNull();
|
|
expect(template.fromName).toBeNull();
|
|
expect(template.replyTo).toBeNull();
|
|
});
|
|
|
|
it('should default to MARKETING type when not specified', async () => {
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Newsletter',
|
|
subject: 'Monthly Update',
|
|
body: 'Content',
|
|
from: 'news@example.com',
|
|
});
|
|
|
|
expect(template.type).toBe(TemplateType.MARKETING);
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should retrieve a template by ID', async () => {
|
|
const created = await factories.createTemplate({
|
|
projectId,
|
|
name: 'Test Template',
|
|
});
|
|
|
|
const retrieved = await TemplateService.get(projectId, created.id);
|
|
|
|
expect(retrieved.id).toBe(created.id);
|
|
expect(retrieved.name).toBe('Test Template');
|
|
});
|
|
|
|
it('should throw 404 when template not found', async () => {
|
|
await expect(TemplateService.get(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
|
|
});
|
|
|
|
it('should throw 404 when template belongs to different project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
const template = await factories.createTemplate({
|
|
projectId: otherProject.id,
|
|
});
|
|
|
|
await expect(TemplateService.get(projectId, template.id)).rejects.toThrow('Template not found');
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('should list templates with pagination', async () => {
|
|
// Create 25 templates
|
|
for (let i = 0; i < 25; i++) {
|
|
await factories.createTemplate({
|
|
projectId,
|
|
name: `Template ${i}`,
|
|
});
|
|
}
|
|
|
|
const page1 = await TemplateService.list(projectId, 1, 10);
|
|
expect(page1.data).toHaveLength(10);
|
|
expect(page1.total).toBe(25);
|
|
expect(page1.page).toBe(1);
|
|
expect(page1.pageSize).toBe(10);
|
|
expect(page1.totalPages).toBe(3);
|
|
|
|
const page2 = await TemplateService.list(projectId, 2, 10);
|
|
expect(page2.data).toHaveLength(10);
|
|
expect(page2.page).toBe(2);
|
|
|
|
const page3 = await TemplateService.list(projectId, 3, 10);
|
|
expect(page3.data).toHaveLength(5);
|
|
expect(page3.page).toBe(3);
|
|
});
|
|
|
|
it('should filter templates by search query (name)', async () => {
|
|
await factories.createTemplate({projectId, name: 'Welcome Email'});
|
|
await factories.createTemplate({projectId, name: 'Password Reset'});
|
|
await factories.createTemplate({projectId, name: 'Welcome Message'});
|
|
|
|
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
|
|
|
expect(result.total).toBe(2);
|
|
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
|
});
|
|
|
|
it('should filter templates by search query (description)', async () => {
|
|
await prisma.template.create({
|
|
data: {
|
|
projectId,
|
|
name: 'Template 1',
|
|
description: 'For new users',
|
|
subject: 'Subject',
|
|
body: 'Body',
|
|
from: 'test@example.com',
|
|
},
|
|
});
|
|
await prisma.template.create({
|
|
data: {
|
|
projectId,
|
|
name: 'Template 2',
|
|
description: 'For existing customers',
|
|
subject: 'Subject',
|
|
body: 'Body',
|
|
from: 'test@example.com',
|
|
},
|
|
});
|
|
await prisma.template.create({
|
|
data: {
|
|
projectId,
|
|
name: 'Template 3',
|
|
description: 'For new subscribers',
|
|
subject: 'Subject',
|
|
body: 'Body',
|
|
from: 'test@example.com',
|
|
},
|
|
});
|
|
|
|
const result = await TemplateService.list(projectId, 1, 20, 'new');
|
|
|
|
expect(result.total).toBe(2);
|
|
expect(result.data.map(t => t.description)).toEqual(
|
|
expect.arrayContaining([expect.stringContaining('new')]),
|
|
);
|
|
});
|
|
|
|
it('should filter templates by search query (subject)', async () => {
|
|
await factories.createTemplate({
|
|
projectId,
|
|
subject: 'Welcome to our platform',
|
|
});
|
|
await factories.createTemplate({
|
|
projectId,
|
|
subject: 'Reset your password',
|
|
});
|
|
await factories.createTemplate({
|
|
projectId,
|
|
subject: 'Welcome back!',
|
|
});
|
|
|
|
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
|
|
|
expect(result.total).toBe(2);
|
|
});
|
|
|
|
it('should filter templates by type', async () => {
|
|
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
|
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
|
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
|
|
await factories.createTemplate({projectId, type: TemplateType.HEADLESS});
|
|
|
|
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
|
expect(marketingResult.total).toBe(2);
|
|
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
|
|
|
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
|
expect(transactionalResult.total).toBe(1);
|
|
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
|
|
|
const headlessResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.HEADLESS);
|
|
expect(headlessResult.total).toBe(1);
|
|
expect(headlessResult.data[0].type).toBe(TemplateType.HEADLESS);
|
|
});
|
|
|
|
it('should combine search and type filters', async () => {
|
|
await factories.createTemplate({
|
|
projectId,
|
|
name: 'Welcome Email',
|
|
type: TemplateType.MARKETING,
|
|
});
|
|
await factories.createTemplate({
|
|
projectId,
|
|
name: 'Welcome SMS',
|
|
type: TemplateType.TRANSACTIONAL,
|
|
});
|
|
await factories.createTemplate({
|
|
projectId,
|
|
name: 'Newsletter',
|
|
type: TemplateType.MARKETING,
|
|
});
|
|
|
|
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
|
|
|
|
expect(result.total).toBe(1);
|
|
expect(result.data[0].name).toBe('Welcome Email');
|
|
});
|
|
|
|
it('should return templates ordered by creation date (newest first)', async () => {
|
|
const template1 = await factories.createTemplate({projectId, name: 'First'});
|
|
// Small delay to ensure different timestamps
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
const template2 = await factories.createTemplate({projectId, name: 'Second'});
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
const template3 = await factories.createTemplate({projectId, name: 'Third'});
|
|
|
|
const result = await TemplateService.list(projectId, 1, 20);
|
|
|
|
expect(result.data[0].id).toBe(template3.id); // Newest
|
|
expect(result.data[1].id).toBe(template2.id);
|
|
expect(result.data[2].id).toBe(template1.id); // Oldest
|
|
});
|
|
|
|
it('should only return templates for the specified project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
|
|
await factories.createTemplate({projectId});
|
|
await factories.createTemplate({projectId});
|
|
await factories.createTemplate({projectId: otherProject.id});
|
|
|
|
const result = await TemplateService.list(projectId);
|
|
|
|
expect(result.total).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should update template name', async () => {
|
|
const template = await factories.createTemplate({
|
|
projectId,
|
|
name: 'Old Name',
|
|
});
|
|
|
|
const updated = await TemplateService.update(projectId, template.id, {
|
|
name: 'New Name',
|
|
});
|
|
|
|
expect(updated.name).toBe('New Name');
|
|
});
|
|
|
|
it('should update template body and subject', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
|
|
const updated = await TemplateService.update(projectId, template.id, {
|
|
subject: 'New Subject',
|
|
body: '<p>New body content</p>',
|
|
});
|
|
|
|
expect(updated.subject).toBe('New Subject');
|
|
expect(updated.body).toBe('<p>New body content</p>');
|
|
});
|
|
|
|
it('should update template type', async () => {
|
|
const template = await factories.createTemplate({
|
|
projectId,
|
|
type: TemplateType.MARKETING,
|
|
});
|
|
|
|
const updated = await TemplateService.update(projectId, template.id, {
|
|
type: TemplateType.TRANSACTIONAL,
|
|
});
|
|
|
|
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
|
|
});
|
|
|
|
it('should update template type to HEADLESS', async () => {
|
|
const template = await factories.createTemplate({
|
|
projectId,
|
|
type: TemplateType.MARKETING,
|
|
});
|
|
|
|
const updated = await TemplateService.update(projectId, template.id, {
|
|
type: TemplateType.HEADLESS,
|
|
});
|
|
|
|
expect(updated.type).toBe(TemplateType.HEADLESS);
|
|
});
|
|
|
|
it('should update email fields (from, fromName, replyTo)', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
|
|
const updated = await TemplateService.update(projectId, template.id, {
|
|
from: 'new@example.com',
|
|
fromName: 'New Name',
|
|
replyTo: 'reply@example.com',
|
|
});
|
|
|
|
expect(updated.from).toBe('new@example.com');
|
|
expect(updated.fromName).toBe('New Name');
|
|
expect(updated.replyTo).toBe('reply@example.com');
|
|
});
|
|
|
|
it('should throw 404 when updating non-existent template', async () => {
|
|
await expect(TemplateService.update(projectId, 'non-existent-id', {name: 'New Name'})).rejects.toThrow(
|
|
'Template not found',
|
|
);
|
|
});
|
|
|
|
it('should throw 404 when updating template from different project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
const template = await factories.createTemplate({projectId: otherProject.id});
|
|
|
|
await expect(TemplateService.update(projectId, template.id, {name: 'New Name'})).rejects.toThrow(
|
|
'Template not found',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete a template', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
|
|
await TemplateService.delete(projectId, template.id);
|
|
|
|
const deleted = await prisma.template.findUnique({
|
|
where: {id: template.id},
|
|
});
|
|
|
|
expect(deleted).toBeNull();
|
|
});
|
|
|
|
it('should throw 404 when deleting non-existent template', async () => {
|
|
await expect(TemplateService.delete(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
|
|
});
|
|
|
|
it('should BLOCK deleting template used in workflow steps', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
const workflow = await factories.createWorkflow({projectId});
|
|
await factories.createWorkflowStep({
|
|
workflowId: workflow.id,
|
|
templateId: template.id,
|
|
type: 'SEND_EMAIL',
|
|
});
|
|
|
|
await expect(TemplateService.delete(projectId, template.id)).rejects.toThrow(/currently used in workflow steps/i);
|
|
});
|
|
|
|
it('should ALLOW deleting template that was used but no longer in workflows', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
const workflow = await factories.createWorkflow({projectId});
|
|
const step = await factories.createWorkflowStep({
|
|
workflowId: workflow.id,
|
|
templateId: template.id,
|
|
type: 'SEND_EMAIL',
|
|
});
|
|
|
|
// Remove template from workflow step
|
|
await prisma.workflowStep.update({
|
|
where: {id: step.id},
|
|
data: {templateId: null},
|
|
});
|
|
|
|
// Should now be deletable
|
|
await TemplateService.delete(projectId, template.id);
|
|
|
|
const deleted = await prisma.template.findUnique({
|
|
where: {id: template.id},
|
|
});
|
|
|
|
expect(deleted).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('duplicate', () => {
|
|
it('should duplicate a template with (Copy) suffix', async () => {
|
|
const original = await factories.createTemplate({
|
|
projectId,
|
|
name: 'Original Template',
|
|
description: 'Original description',
|
|
subject: 'Original subject',
|
|
body: 'Original body',
|
|
from: 'original@example.com',
|
|
fromName: 'Original Name',
|
|
replyTo: 'reply@example.com',
|
|
type: TemplateType.TRANSACTIONAL,
|
|
});
|
|
|
|
const duplicate = await TemplateService.duplicate(projectId, original.id);
|
|
|
|
expect(duplicate.id).not.toBe(original.id);
|
|
expect(duplicate.name).toBe('Original Template (Copy)');
|
|
expect(duplicate.description).toBe(original.description);
|
|
expect(duplicate.subject).toBe(original.subject);
|
|
expect(duplicate.body).toBe(original.body);
|
|
expect(duplicate.from).toBe(original.from);
|
|
expect(duplicate.fromName).toBe(original.fromName);
|
|
expect(duplicate.replyTo).toBe(original.replyTo);
|
|
expect(duplicate.type).toBe(original.type);
|
|
expect(duplicate.projectId).toBe(projectId);
|
|
});
|
|
|
|
it('should handle duplicating template with null optional fields', async () => {
|
|
const original = await prisma.template.create({
|
|
data: {
|
|
projectId,
|
|
name: 'Minimal Template',
|
|
subject: 'Subject',
|
|
body: 'Body',
|
|
from: 'from@example.com',
|
|
description: null,
|
|
fromName: null,
|
|
replyTo: null,
|
|
},
|
|
});
|
|
|
|
const duplicate = await TemplateService.duplicate(projectId, original.id);
|
|
|
|
expect(duplicate.name).toBe('Minimal Template (Copy)');
|
|
expect(duplicate.description).toBeNull();
|
|
expect(duplicate.fromName).toBeNull();
|
|
expect(duplicate.replyTo).toBeNull();
|
|
});
|
|
|
|
it('should throw 404 when duplicating non-existent template', async () => {
|
|
await expect(TemplateService.duplicate(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// TEMPLATE USAGE TRACKING
|
|
// ========================================
|
|
describe('getUsage', () => {
|
|
it('should return usage statistics for template', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
const workflow = await factories.createWorkflow({projectId});
|
|
|
|
// Create workflow steps using this template
|
|
await factories.createWorkflowStep({
|
|
workflowId: workflow.id,
|
|
templateId: template.id,
|
|
type: 'SEND_EMAIL',
|
|
});
|
|
await factories.createWorkflowStep({
|
|
workflowId: workflow.id,
|
|
templateId: template.id,
|
|
type: 'SEND_EMAIL',
|
|
});
|
|
|
|
// Create emails sent using this template
|
|
const contact = await factories.createContact({projectId});
|
|
await factories.createEmail(projectId, contact.id, {templateId: template.id});
|
|
await factories.createEmail(projectId, contact.id, {templateId: template.id});
|
|
await factories.createEmail(projectId, contact.id, {templateId: template.id});
|
|
|
|
const usage = await TemplateService.getUsage(projectId, template.id);
|
|
|
|
expect(usage.workflowSteps).toBe(2);
|
|
expect(usage.emailsSent).toBe(3);
|
|
});
|
|
|
|
it('should return zero usage for unused template', async () => {
|
|
const template = await factories.createTemplate({projectId});
|
|
|
|
const usage = await TemplateService.getUsage(projectId, template.id);
|
|
|
|
expect(usage.workflowSteps).toBe(0);
|
|
expect(usage.emailsSent).toBe(0);
|
|
});
|
|
|
|
it('should only count usage within the project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
|
|
const template = await factories.createTemplate({projectId});
|
|
|
|
// Create workflow steps in different project (shouldn't count)
|
|
const otherWorkflow = await factories.createWorkflow({projectId: otherProject.id});
|
|
await factories.createWorkflowStep({
|
|
workflowId: otherWorkflow.id,
|
|
templateId: template.id, // Using same template ID (cross-project reference)
|
|
type: 'SEND_EMAIL',
|
|
});
|
|
|
|
const usage = await TemplateService.getUsage(projectId, template.id);
|
|
|
|
// Should not count workflow step from other project
|
|
expect(usage.workflowSteps).toBe(0);
|
|
});
|
|
|
|
it('should throw 404 when getting usage for non-existent template', async () => {
|
|
await expect(TemplateService.getUsage(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// EDGE CASES & DATA INTEGRITY
|
|
// ========================================
|
|
describe('edge cases', () => {
|
|
it('should handle templates with HTML content', async () => {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<h1>Hello {{firstName}}</h1>
|
|
<p>Welcome to {{company}}</p>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'HTML Template',
|
|
subject: 'Welcome',
|
|
body: html,
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
expect(template.body).toBe(html);
|
|
|
|
const retrieved = await TemplateService.get(projectId, template.id);
|
|
expect(retrieved.body).toBe(html);
|
|
});
|
|
|
|
it('should handle templates with variable placeholders', async () => {
|
|
const body = 'Hello {{firstName}} {{lastName}}, your code is {{verificationCode}}';
|
|
const subject = 'Welcome {{firstName}} - {{company}}';
|
|
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Variables Test',
|
|
subject,
|
|
body,
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
expect(template.subject).toBe(subject);
|
|
expect(template.body).toBe(body);
|
|
});
|
|
|
|
it('should handle templates with special characters', async () => {
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Special Chars: @#$%^&*()',
|
|
subject: 'Émojis 🎉 & Spëcial Çhars',
|
|
body: '<p>Price: $100 • Discount: 20%</p>',
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
expect(template.name).toBe('Special Chars: @#$%^&*()');
|
|
expect(template.subject).toContain('🎉');
|
|
expect(template.body).toContain('$100');
|
|
});
|
|
|
|
it('should handle very long template content', async () => {
|
|
const longBody = '<p>' + 'Lorem ipsum '.repeat(1000) + '</p>';
|
|
|
|
const template = await TemplateService.create(projectId, {
|
|
name: 'Long Template',
|
|
subject: 'Test',
|
|
body: longBody,
|
|
from: 'test@example.com',
|
|
});
|
|
|
|
expect(template.body.length).toBeGreaterThan(10000);
|
|
});
|
|
});
|
|
});
|