Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
import {describe, it, expect, beforeEach, 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('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', 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('[email protected]', project2.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', 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('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', 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('[email protected]', 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('[email protected]', project.id);
|
||||
const result2 = await DomainService.verifyEmailDomain('[email protected]', project.id);
|
||||
const result3 = await DomainService.verifyEmailDomain('[email protected]', 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: '[email protected]',
|
||||
});
|
||||
|
||||
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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
});
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: '[email protected]',
|
||||
});
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: '[email protected]',
|
||||
});
|
||||
|
||||
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('[email protected]', 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('[email protected]', project.id);
|
||||
|
||||
expect(result.domain).toBe('mail.example.com');
|
||||
|
||||
// Different subdomain should fail
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user