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: '
New body content
', }); expect(updated.subject).toBe('New Subject'); expect(updated.body).toBe('New body content
'); }); 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 = `Welcome to {{company}}
`; 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: 'Price: $100 • Discount: 20%
', 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 = '' + 'Lorem ipsum '.repeat(1000) + '
'; const template = await TemplateService.create(projectId, { name: 'Long Template', subject: 'Test', body: longBody, from: 'test@example.com', }); expect(template.body.length).toBeGreaterThan(10000); }); }); });