/** * 验证码服务测试 * * 功能测试: * - 验证码生成功能 * - 验证码验证功能 * - 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; let mockRedis: jest.Mocked; 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); 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 = { '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}$/); }); }); });