- Replace boolean assertions with structured result object checks in email service tests - Update email service tests to verify success flag and isTestMode property - Add error message assertions for failed email sending scenarios - Change logger spy from 'log' to 'warn' for test mode email output - Update test message to clarify emails are not actually sent in test mode - Add code and createdAt properties to verification code stats mock data - Fix TTL mock value from -1 to -2 to correctly represent non-existent keys - Replace Inject decorator with direct UsersService type injection in LoginCoreService - Ensure verification service tests properly mock TTL values during code verification - Improve test coverage by validating complete response structures instead of simple booleans
590 lines
18 KiB
TypeScript
590 lines
18 KiB
TypeScript
/**
|
||
* 验证码服务测试
|
||
*
|
||
* 功能测试:
|
||
* - 验证码生成功能
|
||
* - 验证码验证功能
|
||
* - Redis连接和操作
|
||
* - 频率限制机制
|
||
* - 错误处理
|
||
* - 验证码统计信息
|
||
*
|
||
* @author moyin
|
||
* @version 1.0.0
|
||
* @since 2025-12-17
|
||
*/
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||
import { VerificationService, VerificationCodeType } from './verification.service';
|
||
import { IRedisService } from '../../redis/redis.interface';
|
||
|
||
describe('VerificationService', () => {
|
||
let service: VerificationService;
|
||
let configService: jest.Mocked<ConfigService>;
|
||
let mockRedis: jest.Mocked<IRedisService>;
|
||
|
||
beforeEach(async () => {
|
||
// 创建 mock Redis 服务
|
||
mockRedis = {
|
||
set: jest.fn(),
|
||
get: jest.fn(),
|
||
del: jest.fn(),
|
||
exists: jest.fn(),
|
||
expire: jest.fn(),
|
||
ttl: jest.fn(),
|
||
flushall: jest.fn(),
|
||
} as any;
|
||
|
||
// Mock ConfigService
|
||
const mockConfigService = {
|
||
get: jest.fn(),
|
||
};
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
VerificationService,
|
||
{
|
||
provide: ConfigService,
|
||
useValue: mockConfigService,
|
||
},
|
||
{
|
||
provide: 'REDIS_SERVICE',
|
||
useValue: mockRedis,
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<VerificationService>(VerificationService);
|
||
configService = module.get(ConfigService);
|
||
});
|
||
|
||
afterEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
describe('初始化测试', () => {
|
||
it('应该正确初始化验证码服务', () => {
|
||
expect(service).toBeDefined();
|
||
expect(mockRedis).toBeDefined();
|
||
});
|
||
|
||
it('应该使用默认Redis配置', () => {
|
||
// 创建新的 mock ConfigService 来测试默认配置
|
||
const testConfigService = {
|
||
get: jest.fn((key: string, defaultValue?: any) => defaultValue),
|
||
};
|
||
|
||
// 创建 mock Redis 服务
|
||
const mockRedisService = {
|
||
set: jest.fn(),
|
||
get: jest.fn(),
|
||
del: jest.fn(),
|
||
exists: jest.fn(),
|
||
expire: jest.fn(),
|
||
ttl: jest.fn(),
|
||
flushall: jest.fn(),
|
||
};
|
||
|
||
new VerificationService(testConfigService as any, mockRedisService as any);
|
||
|
||
// 由于现在使用注入的Redis服务,不再直接创建Redis实例
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
it('应该使用自定义Redis配置', () => {
|
||
// 创建新的 mock ConfigService 来测试自定义配置
|
||
const testConfigService = {
|
||
get: jest.fn((key: string, defaultValue?: any) => {
|
||
const config: Record<string, any> = {
|
||
'REDIS_HOST': 'redis.example.com',
|
||
'REDIS_PORT': 6380,
|
||
'REDIS_PASSWORD': 'password123',
|
||
'REDIS_DB': 1,
|
||
};
|
||
return config[key] !== undefined ? config[key] : defaultValue;
|
||
}),
|
||
};
|
||
|
||
// 创建 mock Redis 服务
|
||
const mockRedisService = {
|
||
set: jest.fn(),
|
||
get: jest.fn(),
|
||
del: jest.fn(),
|
||
exists: jest.fn(),
|
||
expire: jest.fn(),
|
||
ttl: jest.fn(),
|
||
flushall: jest.fn(),
|
||
};
|
||
|
||
new VerificationService(testConfigService as any, mockRedisService as any);
|
||
|
||
// 由于现在使用注入的Redis服务,不再直接创建Redis实例
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
it('应该正确注入Redis服务', () => {
|
||
expect(mockRedis).toBeDefined();
|
||
expect(typeof mockRedis.set).toBe('function');
|
||
expect(typeof mockRedis.get).toBe('function');
|
||
});
|
||
});
|
||
|
||
describe('generateCode', () => {
|
||
beforeEach(() => {
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
});
|
||
|
||
it('应该成功生成邮箱验证码', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
const code = await service.generateCode(email, type);
|
||
|
||
expect(code).toMatch(/^\d{6}$/); // 6位数字
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_code:${type}:${email}`,
|
||
expect.stringContaining(code),
|
||
300 // 5分钟
|
||
);
|
||
});
|
||
|
||
it('应该成功生成密码重置验证码', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.PASSWORD_RESET;
|
||
|
||
const code = await service.generateCode(email, type);
|
||
|
||
expect(code).toMatch(/^\d{6}$/);
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_code:${type}:${email}`,
|
||
expect.stringContaining(code),
|
||
300
|
||
);
|
||
});
|
||
|
||
it('应该在冷却时间内抛出频率限制错误', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 冷却时间存在
|
||
mockRedis.exists.mockResolvedValueOnce(true);
|
||
mockRedis.ttl.mockResolvedValue(30);
|
||
|
||
await expect(service.generateCode(email, type)).rejects.toThrow(
|
||
new HttpException('请等待 30 秒后再试', HttpStatus.TOO_MANY_REQUESTS)
|
||
);
|
||
});
|
||
|
||
it('应该在每小时发送次数达到上限时抛出错误', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 冷却时间不存在,但每小时次数达到上限
|
||
mockRedis.exists.mockResolvedValueOnce(false);
|
||
mockRedis.get.mockResolvedValueOnce('5'); // 已达到上限
|
||
|
||
await expect(service.generateCode(email, type)).rejects.toThrow(
|
||
new HttpException('每小时发送次数已达上限,请稍后再试', HttpStatus.TOO_MANY_REQUESTS)
|
||
);
|
||
});
|
||
|
||
it('应该记录发送尝试', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
await service.generateCode(email, type);
|
||
|
||
// 验证冷却时间设置
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_cooldown:${type}:${email}`,
|
||
'1',
|
||
60
|
||
);
|
||
|
||
// 验证每小时计数
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
expect.stringMatching(/verification_hourly:/),
|
||
'1',
|
||
3600
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('verifyCode', () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
const code = '123456';
|
||
|
||
it('应该成功验证正确的验证码', async () => {
|
||
const codeInfo = {
|
||
code: '123456',
|
||
createdAt: Date.now(),
|
||
attempts: 0,
|
||
maxAttempts: 3,
|
||
};
|
||
|
||
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||
mockRedis.del.mockResolvedValue(true);
|
||
|
||
const result = await service.verifyCode(email, type, code);
|
||
|
||
expect(result).toBe(true);
|
||
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||
});
|
||
|
||
it('应该在验证码不存在时抛出错误', async () => {
|
||
mockRedis.get.mockResolvedValue(null);
|
||
|
||
await expect(service.verifyCode(email, type, code)).rejects.toThrow(
|
||
new BadRequestException('验证码不存在或已过期')
|
||
);
|
||
});
|
||
|
||
it('应该在尝试次数过多时抛出错误', async () => {
|
||
const codeInfo = {
|
||
code: '123456',
|
||
createdAt: Date.now(),
|
||
attempts: 3,
|
||
maxAttempts: 3,
|
||
};
|
||
|
||
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||
mockRedis.del.mockResolvedValue(true);
|
||
|
||
await expect(service.verifyCode(email, type, code)).rejects.toThrow(
|
||
new BadRequestException('验证码尝试次数过多,请重新获取')
|
||
);
|
||
|
||
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||
});
|
||
|
||
it('应该在验证码错误时增加尝试次数并抛出错误', async () => {
|
||
const codeInfo = {
|
||
code: '123456',
|
||
createdAt: Date.now(),
|
||
attempts: 1,
|
||
maxAttempts: 3,
|
||
};
|
||
|
||
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
|
||
|
||
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
|
||
new BadRequestException('验证码错误,剩余尝试次数: 1')
|
||
);
|
||
|
||
// 验证尝试次数增加
|
||
const updatedCodeInfo = {
|
||
...codeInfo,
|
||
attempts: 2,
|
||
};
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_code:${type}:${email}`,
|
||
JSON.stringify(updatedCodeInfo),
|
||
240
|
||
);
|
||
});
|
||
|
||
it('应该在最后一次尝试失败时显示正确的剩余次数', async () => {
|
||
const codeInfo = {
|
||
code: '123456',
|
||
createdAt: Date.now(),
|
||
attempts: 2,
|
||
maxAttempts: 3,
|
||
};
|
||
|
||
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
|
||
|
||
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
|
||
new BadRequestException('验证码错误,剩余尝试次数: 0')
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('codeExists', () => {
|
||
it('应该在验证码存在时返回true', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.exists.mockResolvedValue(true);
|
||
|
||
const result = await service.codeExists(email, type);
|
||
|
||
expect(result).toBe(true);
|
||
expect(mockRedis.exists).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||
});
|
||
|
||
it('应该在验证码不存在时返回false', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
|
||
const result = await service.codeExists(email, type);
|
||
|
||
expect(result).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('deleteCode', () => {
|
||
it('应该成功删除验证码', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.del.mockResolvedValue(true);
|
||
|
||
await service.deleteCode(email, type);
|
||
|
||
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||
});
|
||
});
|
||
|
||
describe('getCodeTTL', () => {
|
||
it('应该返回验证码剩余时间', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.ttl.mockResolvedValue(180); // 3分钟
|
||
|
||
const result = await service.getCodeTTL(email, type);
|
||
|
||
expect(result).toBe(180);
|
||
expect(mockRedis.ttl).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||
});
|
||
|
||
it('应该在验证码不存在时返回-1', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.ttl.mockResolvedValue(-2); // Redis返回-2表示键不存在
|
||
|
||
const result = await service.getCodeTTL(email, type);
|
||
|
||
expect(result).toBe(-2);
|
||
});
|
||
});
|
||
|
||
describe('getCodeStats', () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
it('应该返回存在的验证码统计信息', async () => {
|
||
const codeInfo = {
|
||
code: '123456',
|
||
createdAt: Date.now(),
|
||
attempts: 1,
|
||
maxAttempts: 3,
|
||
};
|
||
|
||
mockRedis.exists.mockResolvedValue(true);
|
||
mockRedis.ttl.mockResolvedValue(240);
|
||
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||
|
||
const result = await service.getCodeStats(email, type);
|
||
|
||
expect(result).toEqual({
|
||
exists: true,
|
||
ttl: 240,
|
||
attempts: 1,
|
||
maxAttempts: 3,
|
||
code: '123456',
|
||
createdAt: expect.any(Number),
|
||
});
|
||
});
|
||
|
||
it('应该在验证码不存在时返回基本信息', async () => {
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.ttl.mockResolvedValue(-2); // -2 表示键不存在
|
||
|
||
const result = await service.getCodeStats(email, type);
|
||
|
||
expect(result).toEqual({
|
||
exists: false,
|
||
ttl: -2, // 修改为 -2
|
||
});
|
||
});
|
||
|
||
it('应该处理无效的验证码信息', async () => {
|
||
mockRedis.exists.mockResolvedValue(true);
|
||
mockRedis.ttl.mockResolvedValue(240);
|
||
mockRedis.get.mockResolvedValue('invalid json');
|
||
|
||
const result = await service.getCodeStats(email, type);
|
||
|
||
expect(result).toEqual({
|
||
exists: true,
|
||
ttl: 240,
|
||
attempts: undefined,
|
||
maxAttempts: undefined,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('cleanupExpiredCodes', () => {
|
||
it('应该成功执行清理任务', async () => {
|
||
await service.cleanupExpiredCodes();
|
||
// 由于这个方法主要是日志记录,我们只需要确保它不抛出错误
|
||
expect(true).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('私有方法测试', () => {
|
||
it('应该生成正确格式的Redis键', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
await service.generateCode(email, type);
|
||
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_code:${type}:${email}`,
|
||
expect.any(String),
|
||
expect.any(Number)
|
||
);
|
||
});
|
||
|
||
it('应该生成正确格式的冷却时间键', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
await service.generateCode(email, type);
|
||
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
`verification_cooldown:${type}:${email}`,
|
||
'1',
|
||
60
|
||
);
|
||
});
|
||
|
||
it('应该生成正确格式的每小时限制键', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
await service.generateCode(email, type);
|
||
|
||
const hour = new Date().getHours();
|
||
const date = new Date().toDateString();
|
||
const expectedKey = `verification_hourly:${type}:${email}:${date}:${hour}`;
|
||
|
||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||
expectedKey,
|
||
'1',
|
||
3600
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('错误处理测试', () => {
|
||
it('应该处理Redis连接错误', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
mockRedis.exists.mockRejectedValue(new Error('Redis connection failed'));
|
||
|
||
await expect(service.generateCode(email, type)).rejects.toThrow('Redis connection failed');
|
||
});
|
||
|
||
it('应该处理Redis操作错误', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
const code = '123456';
|
||
|
||
mockRedis.get.mockRejectedValue(new Error('Redis operation failed'));
|
||
|
||
await expect(service.verifyCode(email, type, code)).rejects.toThrow('Redis operation failed');
|
||
});
|
||
|
||
it('应该处理JSON解析错误', async () => {
|
||
const email = 'test@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
const code = '123456';
|
||
|
||
mockRedis.get.mockResolvedValue('invalid json string');
|
||
|
||
await expect(service.verifyCode(email, type, code)).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('验证码类型测试', () => {
|
||
it('应该支持所有验证码类型', async () => {
|
||
const email = 'test@example.com';
|
||
const types = [
|
||
VerificationCodeType.EMAIL_VERIFICATION,
|
||
VerificationCodeType.PASSWORD_RESET,
|
||
VerificationCodeType.SMS_VERIFICATION,
|
||
];
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
for (const type of types) {
|
||
const code = await service.generateCode(email, type);
|
||
expect(code).toMatch(/^\d{6}$/);
|
||
}
|
||
|
||
expect(mockRedis.set).toHaveBeenCalledTimes(types.length * 3); // 每个类型调用3次set
|
||
});
|
||
});
|
||
|
||
describe('边界条件测试', () => {
|
||
it('应该处理空字符串标识符', async () => {
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
const code = await service.generateCode('', type);
|
||
expect(code).toMatch(/^\d{6}$/);
|
||
});
|
||
|
||
it('应该处理特殊字符标识符', async () => {
|
||
const specialEmail = 'test+special@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
const code = await service.generateCode(specialEmail, type);
|
||
expect(code).toMatch(/^\d{6}$/);
|
||
});
|
||
|
||
it('应该处理长标识符', async () => {
|
||
const longEmail = 'a'.repeat(100) + '@example.com';
|
||
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||
|
||
// Mock 频率限制检查通过
|
||
mockRedis.exists.mockResolvedValue(false);
|
||
mockRedis.get.mockResolvedValue(null);
|
||
mockRedis.set.mockResolvedValue(undefined);
|
||
|
||
const code = await service.generateCode(longEmail, type);
|
||
expect(code).toMatch(/^\d{6}$/);
|
||
});
|
||
});
|
||
}); |