547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|