- Restructure tests with better organization and describe blocks - Add comprehensive test coverage for all service methods - Test edge cases including expired tokens and database errors - Add integration scenario tests for full verification flow - Fix TypeScript issues with mock user creation - Improve test isolation and mock management - Add tests for unique token generation and error handling
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import { EmailVerificationService } from '../emailVerification.service';
|
|
import { AuthenticatedUser } from '../auth.types';
|
|
import { AccountStatus } from '../auth.constants';
|
|
|
|
// Mock the mailgun service
|
|
jest.mock('../../mailgun.service', () => ({
|
|
mailgunService: {
|
|
sendVerificationEmail: jest.fn().mockResolvedValue(true),
|
|
sendPasswordResetEmail: jest.fn().mockResolvedValue(true),
|
|
},
|
|
}));
|
|
|
|
// Mock the couchdb.factory service
|
|
jest.mock('../../couchdb.factory', () => ({
|
|
dbService: {
|
|
findUserByEmail: jest.fn(),
|
|
updateUser: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('EmailVerificationService', () => {
|
|
let emailVerificationService: EmailVerificationService;
|
|
let mockMailgunService: any;
|
|
let mockDbService: any;
|
|
|
|
const createMockUser = (
|
|
overrides: Partial<AuthenticatedUser> = {}
|
|
): AuthenticatedUser => ({
|
|
_id: 'user1',
|
|
_rev: 'mock-rev-1',
|
|
email: 'test@example.com',
|
|
username: 'testuser',
|
|
password: 'password',
|
|
emailVerified: false,
|
|
status: AccountStatus.PENDING,
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
emailVerificationService = new EmailVerificationService();
|
|
// Clear localStorage before each test
|
|
localStorage.clear();
|
|
// Reset all mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Get mocked services
|
|
const mailgunModule = await import('../../mailgun.service');
|
|
const dbModule = await import('../../couchdb.factory');
|
|
mockMailgunService = mailgunModule.mailgunService;
|
|
mockDbService = dbModule.dbService;
|
|
});
|
|
|
|
describe('generateVerificationToken', () => {
|
|
test('should generate a valid verification token', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
mockMailgunService.sendVerificationEmail.mockResolvedValue(true);
|
|
|
|
// Act
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Assert
|
|
expect(verificationToken).toBeDefined();
|
|
expect(verificationToken.token).toBeDefined();
|
|
expect(verificationToken.token).toHaveLength(32); // UUID without hyphens
|
|
expect(verificationToken.expiresAt).toBeDefined();
|
|
expect(verificationToken.userId).toBe(user._id);
|
|
expect(verificationToken.email).toBe(user.email);
|
|
expect(verificationToken.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
|
|
|
// Verify token is stored in localStorage
|
|
const storedTokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
expect(storedTokens).toHaveLength(1);
|
|
expect(storedTokens[0].token).toBe(verificationToken.token);
|
|
expect(storedTokens[0].userId).toBe(user._id);
|
|
expect(storedTokens[0].email).toBe(user.email);
|
|
});
|
|
|
|
test('should send verification email when user has email', async () => {
|
|
// Arrange
|
|
const user = createMockUser({ email: 'user@example.com' });
|
|
mockMailgunService.sendVerificationEmail.mockResolvedValue(true);
|
|
|
|
// Act
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Assert
|
|
expect(mockMailgunService.sendVerificationEmail).toHaveBeenCalledWith(
|
|
user.email,
|
|
verificationToken.token
|
|
);
|
|
});
|
|
|
|
test('should handle email sending failure gracefully', async () => {
|
|
// Arrange
|
|
const user = createMockUser({ email: 'user@example.com' });
|
|
mockMailgunService.sendVerificationEmail.mockResolvedValue(false);
|
|
|
|
// Act
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Assert
|
|
expect(verificationToken).toBeDefined();
|
|
expect(verificationToken.token).toBeDefined();
|
|
expect(mockMailgunService.sendVerificationEmail).toHaveBeenCalledWith(
|
|
user.email,
|
|
verificationToken.token
|
|
);
|
|
});
|
|
|
|
test('should not send email when user has no email', async () => {
|
|
// Arrange
|
|
const user = createMockUser({ email: '' });
|
|
|
|
// Act
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Assert
|
|
expect(verificationToken).toBeDefined();
|
|
expect(mockMailgunService.sendVerificationEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should generate unique tokens for multiple calls', async () => {
|
|
// Arrange
|
|
const user1 = createMockUser({
|
|
_id: 'user1',
|
|
email: 'user1@example.com',
|
|
});
|
|
const user2 = createMockUser({
|
|
_id: 'user2',
|
|
email: 'user2@example.com',
|
|
});
|
|
|
|
// Act
|
|
const token1 =
|
|
await emailVerificationService.generateVerificationToken(user1);
|
|
const token2 =
|
|
await emailVerificationService.generateVerificationToken(user2);
|
|
|
|
// Assert
|
|
expect(token1.token).not.toBe(token2.token);
|
|
expect(token1.userId).toBe('user1');
|
|
expect(token2.userId).toBe('user2');
|
|
|
|
// Verify both tokens are stored
|
|
const storedTokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
expect(storedTokens).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('validateVerificationToken', () => {
|
|
test('should validate a valid token and return user', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
mockDbService.findUserByEmail.mockResolvedValue(user);
|
|
|
|
// Generate and store a token first
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Act
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeDefined();
|
|
expect(validatedUser!._id).toBe(user._id);
|
|
expect(validatedUser!.email).toBe(user.email);
|
|
expect(mockDbService.findUserByEmail).toHaveBeenCalledWith(user.email);
|
|
});
|
|
|
|
test('should return null for non-existent token', async () => {
|
|
// Act
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
'invalid-token'
|
|
);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeNull();
|
|
expect(mockDbService.findUserByEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should return null for expired token', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Manually expire the token in localStorage
|
|
const tokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
tokens[0].expiresAt = new Date(
|
|
Date.now() - 1000 * 60 * 60 * 24
|
|
).toISOString(); // 24 hours ago
|
|
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
|
|
|
|
// Act
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeNull();
|
|
expect(mockDbService.findUserByEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should return null when user not found in database', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
mockDbService.findUserByEmail.mockResolvedValue(null);
|
|
|
|
// Act
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeNull();
|
|
expect(mockDbService.findUserByEmail).toHaveBeenCalledWith(user.email);
|
|
});
|
|
|
|
test('should handle database errors gracefully', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
mockDbService.findUserByEmail.mockRejectedValue(
|
|
new Error('Database error')
|
|
);
|
|
|
|
// Act & Assert
|
|
await expect(
|
|
emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
)
|
|
).rejects.toThrow('Database error');
|
|
});
|
|
});
|
|
|
|
describe('markEmailVerified', () => {
|
|
test('should update user status to active and mark email verified', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
const expectedUpdatedUser = {
|
|
...user,
|
|
emailVerified: true,
|
|
status: AccountStatus.ACTIVE,
|
|
};
|
|
|
|
// Store a verification token first
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
mockDbService.updateUser.mockResolvedValue(expectedUpdatedUser);
|
|
|
|
// Act
|
|
await emailVerificationService.markEmailVerified(user);
|
|
|
|
// Assert
|
|
expect(mockDbService.updateUser).toHaveBeenCalledWith(
|
|
expectedUpdatedUser
|
|
);
|
|
});
|
|
|
|
test('should remove verification token after marking verified', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
mockDbService.updateUser.mockResolvedValue(user);
|
|
|
|
// Verify token exists before
|
|
let tokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
expect(tokens).toHaveLength(1);
|
|
|
|
// Act
|
|
await emailVerificationService.markEmailVerified(user);
|
|
|
|
// Assert - token should be removed
|
|
tokens = JSON.parse(localStorage.getItem('verification_tokens') || '[]');
|
|
expect(tokens).toHaveLength(0);
|
|
});
|
|
|
|
test('should only remove tokens for the specific user', async () => {
|
|
// Arrange
|
|
const user1 = createMockUser({
|
|
_id: 'user1',
|
|
email: 'user1@example.com',
|
|
});
|
|
const user2 = createMockUser({
|
|
_id: 'user2',
|
|
email: 'user2@example.com',
|
|
});
|
|
|
|
await emailVerificationService.generateVerificationToken(user1);
|
|
await emailVerificationService.generateVerificationToken(user2);
|
|
mockDbService.updateUser.mockResolvedValue(user1);
|
|
|
|
// Verify both tokens exist
|
|
let tokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
expect(tokens).toHaveLength(2);
|
|
|
|
// Act - verify only user1
|
|
await emailVerificationService.markEmailVerified(user1);
|
|
|
|
// Assert - only user1's token should be removed
|
|
tokens = JSON.parse(localStorage.getItem('verification_tokens') || '[]');
|
|
expect(tokens).toHaveLength(1);
|
|
expect(tokens[0].userId).toBe('user2');
|
|
});
|
|
|
|
test('should handle database update errors', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
mockDbService.updateUser.mockRejectedValue(new Error('Update failed'));
|
|
|
|
// Act & Assert
|
|
await expect(
|
|
emailVerificationService.markEmailVerified(user)
|
|
).rejects.toThrow('Update failed');
|
|
});
|
|
});
|
|
|
|
describe('sendPasswordResetEmail', () => {
|
|
test('should delegate to mailgun service', async () => {
|
|
// Arrange
|
|
const email = 'user@example.com';
|
|
const token = 'reset-token';
|
|
mockMailgunService.sendPasswordResetEmail.mockResolvedValue(true);
|
|
|
|
// Act
|
|
const result = await emailVerificationService.sendPasswordResetEmail(
|
|
email,
|
|
token
|
|
);
|
|
|
|
// Assert
|
|
expect(result).toBe(true);
|
|
expect(mockMailgunService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
|
email,
|
|
token
|
|
);
|
|
});
|
|
|
|
test('should return false when email sending fails', async () => {
|
|
// Arrange
|
|
const email = 'user@example.com';
|
|
const token = 'reset-token';
|
|
mockMailgunService.sendPasswordResetEmail.mockResolvedValue(false);
|
|
|
|
// Act
|
|
const result = await emailVerificationService.sendPasswordResetEmail(
|
|
email,
|
|
token
|
|
);
|
|
|
|
// Assert
|
|
expect(result).toBe(false);
|
|
expect(mockMailgunService.sendPasswordResetEmail).toHaveBeenCalledWith(
|
|
email,
|
|
token
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('integration scenarios', () => {
|
|
test('should complete full verification flow', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
mockDbService.findUserByEmail.mockResolvedValue(user);
|
|
mockDbService.updateUser.mockResolvedValue({
|
|
...user,
|
|
emailVerified: true,
|
|
status: AccountStatus.ACTIVE,
|
|
});
|
|
|
|
// Act - Generate token
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Act - Validate token
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
);
|
|
|
|
// Act - Mark as verified
|
|
await emailVerificationService.markEmailVerified(validatedUser!);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeDefined();
|
|
expect(mockDbService.updateUser).toHaveBeenCalledWith({
|
|
...user,
|
|
emailVerified: true,
|
|
status: AccountStatus.ACTIVE,
|
|
});
|
|
|
|
// Verify token is cleaned up
|
|
const tokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
expect(tokens).toHaveLength(0);
|
|
});
|
|
|
|
test('should handle token expiration during flow', async () => {
|
|
// Arrange
|
|
const user = createMockUser();
|
|
const verificationToken =
|
|
await emailVerificationService.generateVerificationToken(user);
|
|
|
|
// Simulate time passing - expire the token
|
|
const tokens = JSON.parse(
|
|
localStorage.getItem('verification_tokens') || '[]'
|
|
);
|
|
tokens[0].expiresAt = new Date(Date.now() - 1000).toISOString(); // 1 second ago
|
|
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
|
|
|
|
// Act
|
|
const validatedUser =
|
|
await emailVerificationService.validateVerificationToken(
|
|
verificationToken.token
|
|
);
|
|
|
|
// Assert
|
|
expect(validatedUser).toBeNull();
|
|
});
|
|
});
|
|
});
|