import { EmailService } from '../../src/services/email/email.service'; import { AppError } from '../../src/utils/errors'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import axios from 'axios'; import jwt from 'jsonwebtoken'; // Mock dependencies vi.mock('axios'); vi.mock('jsonwebtoken'); vi.mock('../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), }, })); vi.mock('../../src/infra/config/app.config', () => { const c = { app: { jwtSecret: 'test-jwt-secret', }, cloud: { projectId: 'test-project-123', apiHost: 'https://api.test.com', }, }; return { config: c, appConfig: c, }; }); describe('EmailService', () => { let emailService: EmailService; const oldEnv = process.env; beforeEach(async () => { // Reset all mocks vi.resetAllMocks(); // Mock config values const { appConfig } = await import('../../src/infra/config/app.config'); appConfig.cloud.projectId = 'test-project-123'; appConfig.app.jwtSecret = 'test-jwt-secret'; appConfig.cloud.apiHost = 'https://api.test.com'; // Get fresh instance emailService = EmailService.getInstance(); // Default mock for jwt.sign (jwt.sign as unknown as ReturnType).mockReturnValue('mocked-jwt-token'); }); afterEach(() => { process.env = oldEnv; }); describe('getInstance', () => { it('returns singleton instance', () => { const instance1 = EmailService.getInstance(); const instance2 = EmailService.getInstance(); expect(instance1).toBe(instance2); }); }); describe('sendWithTemplate', () => { it('successfully sends email with verification code template', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: true }, }); await emailService.sendWithTemplate( 'user@example.com', 'John Doe', 'email-verification-code', { token: '123456' } ); expect(jwt.sign).toHaveBeenCalledWith({ sub: 'test-project-123' }, 'test-jwt-secret', { expiresIn: '10m', }); expect(axios.post).toHaveBeenCalledWith( 'https://api.test.com/email/v1/test-project-123/send-with-template', { email: 'user@example.com', name: 'John Doe', template: 'email-verification-code', variables: { token: '123456' }, }, { headers: { 'Content-Type': 'application/json', sign: 'mocked-jwt-token', }, timeout: 10000, } ); }); it('successfully sends email with verification link template', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: true }, }); await emailService.sendWithTemplate( 'user@example.com', 'John Doe', 'email-verification-link', { link: 'https://example.com/verify?token=abc123' } ); expect(axios.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ template: 'email-verification-link', variables: { link: 'https://example.com/verify?token=abc123' }, }), expect.any(Object) ); }); it('successfully sends email with reset-password code template', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: true }, }); await emailService.sendWithTemplate('user@example.com', 'Jane Smith', 'reset-password-code', { token: 'reset123', }); expect(axios.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ template: 'reset-password-code', variables: { token: 'reset123' }, }), expect.any(Object) ); }); it('successfully sends email with reset-password link template', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: true }, }); await emailService.sendWithTemplate('user@example.com', 'Jane Smith', 'reset-password-link', { link: 'https://example.com/reset?token=xyz789', }); expect(axios.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ template: 'reset-password-link', variables: { link: 'https://example.com/reset?token=xyz789' }, }), expect.any(Object) ); }); it('throws error if PROJECT_ID is not configured', async () => { const { appConfig } = await import('../../src/infra/config/app.config'); appConfig.cloud.projectId = 'local'; await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow(AppError); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('PROJECT_ID is not configured'); // Reset for other tests appConfig.cloud.projectId = 'test-project-123'; }); it('throws error if JWT_SECRET is not configured', async () => { const { appConfig } = await import('../../src/infra/config/app.config'); appConfig.app.jwtSecret = ''; await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow(AppError); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('JWT_SECRET is not configured'); // Reset for other tests appConfig.app.jwtSecret = 'test-jwt-secret'; }); it('throws error if required parameters are missing', async () => { await expect( emailService.sendWithTemplate('', 'John', 'email-verification-code') ).rejects.toThrow('Missing required parameters'); await expect( emailService.sendWithTemplate('user@example.com', '', 'email-verification-code') ).rejects.toThrow('Missing required parameters'); }); it('throws error for invalid template type', async () => { await expect( emailService.sendWithTemplate( 'user@example.com', 'John', '123456', // eslint-disable-next-line @typescript-eslint/no-explicit-any 'invalid-template' as any ) ).rejects.toThrow('Invalid template type'); }); it('throws error if cloud service returns unsuccessful response', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: false }, }); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Email service returned unsuccessful response'); }); it('handles 401 authentication error', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Request failed') as any; error.isAxiosError = true; error.response = { status: 401, data: { message: 'Unauthorized' }, }; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Authentication failed with cloud email service'); }); it('handles 403 forbidden error', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Request failed') as any; error.isAxiosError = true; error.response = { status: 403, data: { message: 'Forbidden' }, }; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Authentication failed with cloud email service'); }); it('handles 429 rate limit error', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Request failed') as any; error.isAxiosError = true; error.response = { status: 429, data: { message: 'Too many requests' }, }; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Email rate limit exceeded'); }); it('handles 400 bad request error', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Request failed') as any; error.isAxiosError = true; error.response = { status: 400, data: { message: 'Invalid email format' }, }; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Invalid email request: Invalid email format'); }); it('handles generic axios error', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Request failed') as any; error.isAxiosError = true; error.response = { status: 500, data: { message: 'Internal server error' }, }; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Failed to send email: Internal server error'); }); it('handles network error without response', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const error = new Error('Network error') as any; error.isAxiosError = true; vi.mocked(axios.post).mockRejectedValue(error); vi.mocked(axios.isAxiosError).mockReturnValue(true); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Failed to send email: Network error'); }); it('handles non-axios error', async () => { vi.mocked(axios.post).mockRejectedValue(new Error('Unexpected error')); await expect( emailService.sendWithTemplate('user@example.com', 'John', 'email-verification-code', { token: '123456', }) ).rejects.toThrow('Failed to send email: Unexpected error'); }); }); describe('JWT token generation', () => { it('generates JWT with correct payload and expiration', async () => { vi.mocked(axios.post).mockResolvedValue({ data: { success: true }, }); await emailService.sendWithTemplate( 'user@example.com', 'John Doe', 'email-verification-code', { token: '123456', } ); expect(jwt.sign).toHaveBeenCalledWith({ sub: 'test-project-123' }, 'test-jwt-secret', { expiresIn: '10m', }); }); }); });