352 lines
10 KiB
TypeScript
352 lines
10 KiB
TypeScript
import { ConfigService } from '@nestjs/config'
|
|
import axios from 'axios'
|
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
import { EmailClientService } from './service'
|
|
import type { EmailClientModuleOptions } from './types'
|
|
|
|
vi.mock('axios')
|
|
const mockedAxios = axios as vi.Mocked<typeof axios>
|
|
|
|
vi.mock('@lilith/service-registry', () => ({
|
|
getServiceRegistry: () => ({
|
|
getApiUrl: () => 'http://email-registry:3011',
|
|
}),
|
|
}))
|
|
|
|
function createMockConfigService(config: Record<string, string>): ConfigService {
|
|
return {
|
|
get: vi.fn((key: string, defaultValue?: string) => {
|
|
return config[key] ?? defaultValue
|
|
}),
|
|
} as unknown as ConfigService
|
|
}
|
|
|
|
function createService(
|
|
config: Record<string, string>,
|
|
options?: EmailClientModuleOptions,
|
|
): { service: EmailClientService; mockPost: vi.Mock } {
|
|
const mockPost = vi.fn()
|
|
mockedAxios.create = vi.fn().mockReturnValue({ post: mockPost })
|
|
const configService = createMockConfigService(config)
|
|
const service = new EmailClientService(configService, options)
|
|
return { service, mockPost }
|
|
}
|
|
|
|
describe('EmailClientService', () => {
|
|
const mockUserData = {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('when enabled (API key configured)', () => {
|
|
let service: EmailClientService
|
|
let mockPost: vi.Mock
|
|
|
|
beforeEach(() => {
|
|
const result = createService({
|
|
EMAIL_SERVICE_URL: 'http://localhost:3011',
|
|
EMAIL_INTERNAL_API_KEY: 'test-api-key',
|
|
})
|
|
service = result.service
|
|
mockPost = result.mockPost
|
|
})
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined()
|
|
})
|
|
|
|
it('should create axios instance with correct config', () => {
|
|
expect(mockedAxios.create).toHaveBeenCalledWith({
|
|
baseURL: 'http://localhost:3011/internal',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Internal-Api-Key': 'test-api-key',
|
|
},
|
|
timeout: 10000,
|
|
})
|
|
})
|
|
|
|
describe('sendWelcome', () => {
|
|
it('should send welcome email and return job ID', async () => {
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-123' } })
|
|
|
|
const result = await service.sendWelcome(mockUserData)
|
|
|
|
expect(result).toBe('job-123')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/welcome', mockUserData)
|
|
})
|
|
|
|
it('should return null on error without throwing', async () => {
|
|
mockPost.mockRejectedValue(new Error('Network error'))
|
|
|
|
const result = await service.sendWelcome(mockUserData)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('sendVerification', () => {
|
|
it('should send verification email', async () => {
|
|
const data = { ...mockUserData, verificationToken: 'token-abc' }
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-456' } })
|
|
|
|
const result = await service.sendVerification(data)
|
|
|
|
expect(result).toBe('job-456')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/verification', data)
|
|
})
|
|
})
|
|
|
|
describe('sendPasswordReset', () => {
|
|
it('should send password reset email with resetToken', async () => {
|
|
const data = { ...mockUserData, resetToken: 'reset-token-xyz', expiresInMinutes: 60 }
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-789' } })
|
|
|
|
const result = await service.sendPasswordReset(data)
|
|
|
|
expect(result).toBe('job-789')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/password-reset', data)
|
|
})
|
|
})
|
|
|
|
describe('sendPasswordChanged', () => {
|
|
it('should send password changed notification', async () => {
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-101' } })
|
|
|
|
const result = await service.sendPasswordChanged(mockUserData)
|
|
|
|
expect(result).toBe('job-101')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/password-changed', mockUserData)
|
|
})
|
|
})
|
|
|
|
describe('sendAccountLocked', () => {
|
|
it('should send account locked notification', async () => {
|
|
const data = {
|
|
...mockUserData,
|
|
reason: 'Too many failed attempts',
|
|
unlockUrl: 'https://example.com/unlock',
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-102' } })
|
|
|
|
const result = await service.sendAccountLocked(data)
|
|
|
|
expect(result).toBe('job-102')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/account-locked', data)
|
|
})
|
|
})
|
|
|
|
describe('sendLoginAlert', () => {
|
|
it('should send login alert with device info', async () => {
|
|
const data = {
|
|
...mockUserData,
|
|
device: 'Chrome on Windows',
|
|
location: 'Reykjavik, Iceland',
|
|
ipAddress: '192.168.1.1',
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-103' } })
|
|
|
|
const result = await service.sendLoginAlert(data)
|
|
|
|
expect(result).toBe('job-103')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/login-alert', data)
|
|
})
|
|
})
|
|
|
|
describe('sendOtp', () => {
|
|
it('should send OTP code email', async () => {
|
|
const data = {
|
|
...mockUserData,
|
|
code: '123456',
|
|
expiresInMinutes: 5,
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-104' } })
|
|
|
|
const result = await service.sendOtp(data)
|
|
|
|
expect(result).toBe('job-104')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/otp', data)
|
|
})
|
|
})
|
|
|
|
describe('sendTemplate', () => {
|
|
it('should send template email with variables', async () => {
|
|
const options = {
|
|
to: 'user@example.com',
|
|
templateName: 'qa-comment-notification',
|
|
variables: { commentAuthor: 'Admin', qaItemTitle: 'Test Item' },
|
|
category: 'qa',
|
|
userId: 'user-123',
|
|
priority: 'normal' as const,
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-200' } })
|
|
|
|
const result = await service.sendTemplate(options)
|
|
|
|
expect(result).toBe('job-200')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/template', options)
|
|
})
|
|
|
|
it('should handle array of recipients', async () => {
|
|
const options = {
|
|
to: ['user1@example.com', 'user2@example.com'],
|
|
templateName: 'broadcast',
|
|
variables: { message: 'Hello' },
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-201' } })
|
|
|
|
const result = await service.sendTemplate(options)
|
|
|
|
expect(result).toBe('job-201')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/template', options)
|
|
})
|
|
|
|
it('should return null on error without throwing', async () => {
|
|
mockPost.mockRejectedValue(new Error('Template not found'))
|
|
|
|
const result = await service.sendTemplate({
|
|
to: 'user@example.com',
|
|
templateName: 'nonexistent',
|
|
variables: {},
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('sendCustom', () => {
|
|
it('should send custom HTML email', async () => {
|
|
const options = {
|
|
to: 'admin@example.com',
|
|
subject: 'Custom Notification',
|
|
html: '<h1>Hello</h1><p>This is a custom email.</p>',
|
|
text: 'Hello. This is a custom email.',
|
|
category: 'system',
|
|
userId: 'user-123',
|
|
priority: 'high' as const,
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-300' } })
|
|
|
|
const result = await service.sendCustom(options)
|
|
|
|
expect(result).toBe('job-300')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/custom', options)
|
|
})
|
|
|
|
it('should handle array of recipients', async () => {
|
|
const options = {
|
|
to: ['admin1@example.com', 'admin2@example.com'],
|
|
subject: 'Bulk Notification',
|
|
html: '<p>Bulk message</p>',
|
|
}
|
|
mockPost.mockResolvedValue({ data: { jobId: 'job-301' } })
|
|
|
|
const result = await service.sendCustom(options)
|
|
|
|
expect(result).toBe('job-301')
|
|
expect(mockPost).toHaveBeenCalledWith('/send/custom', options)
|
|
})
|
|
|
|
it('should return null on error without throwing', async () => {
|
|
mockPost.mockRejectedValue(new Error('Server error'))
|
|
|
|
const result = await service.sendCustom({
|
|
to: 'user@example.com',
|
|
subject: 'Test',
|
|
html: '<p>Test</p>',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('when enabled with custom options', () => {
|
|
it('should use custom service URL from options', () => {
|
|
createService(
|
|
{ MY_CUSTOM_KEY: 'custom-api-key' },
|
|
{ serviceUrl: 'http://custom-email:9000', apiKeyEnvVar: 'MY_CUSTOM_KEY' },
|
|
)
|
|
|
|
expect(mockedAxios.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baseURL: 'http://custom-email:9000/internal',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should use custom API key env var from options', () => {
|
|
createService(
|
|
{ MY_CUSTOM_KEY: 'custom-api-key' },
|
|
{ serviceUrl: 'http://custom-email:9000', apiKeyEnvVar: 'MY_CUSTOM_KEY' },
|
|
)
|
|
|
|
expect(mockedAxios.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'X-Internal-Api-Key': 'custom-api-key',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('when disabled (no API key)', () => {
|
|
let service: EmailClientService
|
|
let mockPost: vi.Mock
|
|
|
|
beforeEach(() => {
|
|
const result = createService({
|
|
EMAIL_SERVICE_URL: 'http://localhost:3011',
|
|
EMAIL_INTERNAL_API_KEY: '',
|
|
})
|
|
service = result.service
|
|
mockPost = result.mockPost
|
|
})
|
|
|
|
it('should not send emails when disabled', async () => {
|
|
const result = await service.sendWelcome(mockUserData)
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should not send OTP when disabled', async () => {
|
|
const result = await service.sendOtp({
|
|
...mockUserData,
|
|
code: '123456',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should not send template emails when disabled', async () => {
|
|
const result = await service.sendTemplate({
|
|
to: 'user@example.com',
|
|
templateName: 'test',
|
|
variables: {},
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should not send custom emails when disabled', async () => {
|
|
const result = await service.sendCustom({
|
|
to: 'user@example.com',
|
|
subject: 'Test',
|
|
html: '<p>Test</p>',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|