import {beforeEach, describe, expect, it, vi} from 'vitest'; import {factories, getPrismaClient} from '../../../../../test/helpers'; import {DomainService} from '../DomainService.js'; import {HttpException} from '../../exceptions/index.js'; import * as SESService from '../SESService.js'; /** * Unit tests for DomainService * Focuses on business logic, validation, and edge cases */ describe('DomainService', () => { const prisma = getPrismaClient(); beforeEach(() => { // Mock SES service calls to avoid AWS API calls vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']); vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({ status: 'Success', tokens: ['token1', 'token2', 'token3'], }); }); // ======================================== // ADD DOMAIN // ======================================== describe('addDomain', () => { it('should add a domain and initiate verification', async () => { const {project} = await factories.createUserWithProject(); const domain = 'example.com'; const result = await DomainService.addDomain(project.id, domain); expect(result.domain).toBe(domain); expect(result.projectId).toBe(project.id); expect(result.verified).toBe(false); expect(result.dkimTokens).toEqual(['token1', 'token2', 'token3']); expect(SESService.verifyDomain).toHaveBeenCalledWith(domain); }); it('should call AWS SES to initiate verification', async () => { const {project} = await factories.createUserWithProject(); const domain = 'test-domain.com'; await DomainService.addDomain(project.id, domain); expect(SESService.verifyDomain).toHaveBeenCalledWith(domain); }); }); // ======================================== // CHECK DOMAIN OWNERSHIP // ======================================== describe('checkDomainOwnership', () => { it('should return exists: false for non-existent domain', async () => { const result = await DomainService.checkDomainOwnership('non-existent.com', 'user-id'); expect(result).toEqual({exists: false}); }); it('should return project info when domain exists', async () => { const {user, project} = await factories.createUserWithProject(); await DomainService.addDomain(project.id, 'existing.com'); const result = await DomainService.checkDomainOwnership('existing.com', user.id); expect(result.exists).toBe(true); expect(result.projectId).toBe(project.id); expect(result.projectName).toBe(project.name); expect(result.isMember).toBe(true); }); it('should indicate isMember: false when user is not a member', async () => { const {project: project1} = await factories.createUserWithProject(); const {user: user2} = await factories.createUserWithProject(); await DomainService.addDomain(project1.id, 'restricted.com'); const result = await DomainService.checkDomainOwnership('restricted.com', user2.id); expect(result.exists).toBe(true); expect(result.isMember).toBe(false); }); it('should indicate isMember: true when user is a member', async () => { const {project} = await factories.createUserWithProject(); const user2 = await factories.createUser(); await prisma.membership.create({ data: { userId: user2.id, projectId: project.id, role: 'MEMBER', }, }); await DomainService.addDomain(project.id, 'team-domain.com'); const result = await DomainService.checkDomainOwnership('team-domain.com', user2.id); expect(result.exists).toBe(true); expect(result.isMember).toBe(true); }); }); // ======================================== // VERIFY EMAIL DOMAIN // ======================================== describe('verifyEmailDomain', () => { it('should throw error for invalid email format', async () => { const {project} = await factories.createUserWithProject(); await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException); await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( /invalid email format/i, ); }); it('should throw error when domain is not registered', async () => { const {project} = await factories.createUserWithProject(); await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow( HttpException, ); await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow( /not registered/i, ); }); it('should throw error when domain belongs to different project', async () => { const {project: project1} = await factories.createUserWithProject(); const {project: project2} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project1.id, 'project1.com'); await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(HttpException); await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow( /belongs to a different project/i, ); }); it('should throw error when domain is not verified', async () => { const {project} = await factories.createUserWithProject(); await DomainService.addDomain(project.id, 'unverified.com'); await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(HttpException); await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow( /not verified/i, ); }); it('should return domain when all checks pass', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'verified.com'); await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); const result = await DomainService.verifyEmailDomain('sender@verified.com', project.id); expect(result.domain).toBe('verified.com'); expect(result.verified).toBe(true); expect(result.projectId).toBe(project.id); }); it('should extract domain correctly from various email formats', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'example.com'); await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); // Test various email formats const result1 = await DomainService.verifyEmailDomain('user@example.com', project.id); const result2 = await DomainService.verifyEmailDomain('admin@example.com', project.id); const result3 = await DomainService.verifyEmailDomain('support+tag@example.com', project.id); expect(result1.domain).toBe('example.com'); expect(result2.domain).toBe('example.com'); expect(result3.domain).toBe('example.com'); }); }); // ======================================== // GET DOMAIN BY ID // ======================================== describe('id', () => { it('should return domain by id', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'test.com'); const result = await DomainService.id(domain.id); expect(result).not.toBeNull(); expect(result?.id).toBe(domain.id); expect(result?.domain).toBe('test.com'); }); it('should return null for non-existent id', async () => { const result = await DomainService.id('00000000-0000-0000-0000-000000000000'); expect(result).toBeNull(); }); }); // ======================================== // GET PROJECT DOMAINS // ======================================== describe('getProjectDomains', () => { it('should return all domains for a project', async () => { const {project} = await factories.createUserWithProject(); await DomainService.addDomain(project.id, 'domain1.com'); await DomainService.addDomain(project.id, 'domain2.com'); const domains = await DomainService.getProjectDomains(project.id); expect(domains).toHaveLength(2); expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com']); }); it('should return domains ordered by creation date (newest first)', async () => { const {project} = await factories.createUserWithProject(); // Add domains with slight delay to ensure different timestamps const domain1 = await DomainService.addDomain(project.id, 'first.com'); await new Promise(resolve => setTimeout(resolve, 10)); const domain2 = await DomainService.addDomain(project.id, 'second.com'); await new Promise(resolve => setTimeout(resolve, 10)); const domain3 = await DomainService.addDomain(project.id, 'third.com'); const domains = await DomainService.getProjectDomains(project.id); expect(domains[0].id).toBe(domain3.id); // Newest first expect(domains[1].id).toBe(domain2.id); expect(domains[2].id).toBe(domain1.id); }); it('should return empty array for project with no domains', async () => { const {project} = await factories.createUserWithProject(); const domains = await DomainService.getProjectDomains(project.id); expect(domains).toEqual([]); }); }); // ======================================== // GET VERIFIED DOMAINS // ======================================== describe('getVerifiedDomains', () => { it('should return only verified domains', async () => { const {project} = await factories.createUserWithProject(); const domain1 = await DomainService.addDomain(project.id, 'verified1.com'); await DomainService.addDomain(project.id, 'unverified.com'); const domain3 = await DomainService.addDomain(project.id, 'verified2.com'); // Mark two as verified await prisma.domain.update({ where: {id: domain1.id}, data: {verified: true}, }); await prisma.domain.update({ where: {id: domain3.id}, data: {verified: true}, }); const verifiedDomains = await DomainService.getVerifiedDomains(project.id); expect(verifiedDomains).toHaveLength(2); expect(verifiedDomains.map(d => d.domain).sort()).toEqual(['verified1.com', 'verified2.com']); }); it('should return empty array when no domains are verified', async () => { const {project} = await factories.createUserWithProject(); await DomainService.addDomain(project.id, 'unverified1.com'); await DomainService.addDomain(project.id, 'unverified2.com'); const verifiedDomains = await DomainService.getVerifiedDomains(project.id); expect(verifiedDomains).toEqual([]); }); }); // ======================================== // CHECK VERIFICATION // ======================================== describe('checkVerification', () => { it('should check verification status with AWS SES', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'check-verification.com'); const result = await DomainService.checkVerification(domain.id); expect(result.domain).toBe('check-verification.com'); expect(result.tokens).toEqual(['token1', 'token2', 'token3']); expect(result.status).toBe('Success'); expect(result.verified).toBe(true); expect(SESService.getDomainVerificationAttributes).toHaveBeenCalledWith('check-verification.com'); }); it('should update domain to verified when SES returns Success', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'newly-verified.com'); expect(domain.verified).toBe(false); await DomainService.checkVerification(domain.id); const updated = await prisma.domain.findUnique({where: {id: domain.id}}); expect(updated?.verified).toBe(true); }); it('should update domain to unverified when SES returns Pending', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'pending-domain.com'); // Manually mark as verified await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); // Mock SES to return Pending vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValueOnce({ status: 'Pending', tokens: ['token1', 'token2', 'token3'], }); await DomainService.checkVerification(domain.id); const updated = await prisma.domain.findUnique({where: {id: domain.id}}); expect(updated?.verified).toBe(false); }); it('should throw error for non-existent domain', async () => { await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow( /domain not found/i, ); }); }); // ======================================== // REMOVE DOMAIN // ======================================== describe('removeDomain', () => { it('should remove domain when not in use', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'remove-me.com'); await DomainService.removeDomain(domain.id); const deleted = await prisma.domain.findUnique({where: {id: domain.id}}); expect(deleted).toBeNull(); }); it('should throw error when domain is used in templates', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'used-in-template.com'); await factories.createTemplate({ projectId: project.id, from: 'sender@used-in-template.com', }); await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException); await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i); }); it('should throw error when domain is used in active campaigns', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'used-in-campaign.com'); await factories.createCampaign({ projectId: project.id, from: 'campaign@used-in-campaign.com', status: 'DRAFT', }); await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException); await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i); }); it('should allow removal when campaign is SENT (completed)', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'completed-campaign.com'); await factories.createCampaign({ projectId: project.id, from: 'campaign@completed-campaign.com', status: 'SENT', }); // Should not throw await DomainService.removeDomain(domain.id); const deleted = await prisma.domain.findUnique({where: {id: domain.id}}); expect(deleted).toBeNull(); }); it('should throw error for non-existent domain', async () => { await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow( /domain not found/i, ); }); it('should check usage in multiple templates', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'multi-use.com'); await factories.createTemplate({ projectId: project.id, from: 'sender1@multi-use.com', }); await factories.createTemplate({ projectId: project.id, from: 'sender2@multi-use.com', }); await factories.createTemplate({ projectId: project.id, from: 'sender3@multi-use.com', }); await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/3 template/i); }); }); // ======================================== // EDGE CASES AND ERROR HANDLING // ======================================== describe('Edge Cases', () => { it('should handle emails with plus addressing', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'example.com'); await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); const result = await DomainService.verifyEmailDomain('user+tag@example.com', project.id); expect(result.domain).toBe('example.com'); }); it('should handle subdomain correctly', async () => { const {project} = await factories.createUserWithProject(); const domain = await DomainService.addDomain(project.id, 'mail.example.com'); await prisma.domain.update({ where: {id: domain.id}, data: {verified: true}, }); const result = await DomainService.verifyEmailDomain('sender@mail.example.com', project.id); expect(result.domain).toBe('mail.example.com'); // Different subdomain should fail await expect(DomainService.verifyEmailDomain('sender@other.example.com', project.id)).rejects.toThrow( /not registered/i, ); }); it('should handle email with no @ sign', async () => { const {project} = await factories.createUserWithProject(); await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i); }); it('should handle email with multiple @ signs', async () => { const {project} = await factories.createUserWithProject(); await expect(DomainService.verifyEmailDomain('user@@example.com', project.id)).rejects.toThrow( /invalid email format/i, ); }); it('should handle empty email string', async () => { const {project} = await factories.createUserWithProject(); await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i); }); }); // ======================================== // CONCURRENCY AND RACE CONDITIONS // ======================================== describe('Concurrency', () => { it('should handle concurrent domain additions to same project', async () => { const {project} = await factories.createUserWithProject(); // Add multiple domains concurrently const results = await Promise.all([ DomainService.addDomain(project.id, 'concurrent1.com'), DomainService.addDomain(project.id, 'concurrent2.com'), DomainService.addDomain(project.id, 'concurrent3.com'), ]); expect(results).toHaveLength(3); expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']); }); it('should handle concurrent ownership checks', async () => { const {user, project} = await factories.createUserWithProject(); await DomainService.addDomain(project.id, 'concurrent-check.com'); // Multiple concurrent ownership checks const results = await Promise.all([ DomainService.checkDomainOwnership('concurrent-check.com', user.id), DomainService.checkDomainOwnership('concurrent-check.com', user.id), DomainService.checkDomainOwnership('concurrent-check.com', user.id), ]); // All should return consistent results expect(results.every(r => r.exists)).toBe(true); expect(results.every(r => r.isMember)).toBe(true); }); }); });