377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
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<typeof vi.fn>).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',
|
|
});
|
|
});
|
|
});
|
|
});
|