Files
whale-town-end/src/core/utils/verification/verification.service.spec.ts
moyin bb796a2469 refactor:项目架构重构和命名规范化
- 统一文件命名为snake_case格式(kebab-case  snake_case)
- 重构zulip模块为zulip_core,明确Core层职责
- 重构user-mgmt模块为user_mgmt,统一命名规范
- 调整模块依赖关系,优化架构分层
- 删除过时的文件和目录结构
- 更新相关文档和配置文件

本次重构涉及大量文件重命名和模块重组,
旨在建立更清晰的项目架构和统一的命名规范。
2026-01-08 00:14:14 +08:00

646 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 验证码服务测试
*
* 功能测试:
* - 验证码生成功能
* - 验证码验证功能
* - Redis连接和操作
* - 频率限制机制
* - 错误处理
* - 验证码统计信息
*
* 职责分离:
* - 单元测试覆盖所有公共方法
* - Mock依赖服务进行隔离测试
* - 边界条件和异常情况测试
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录规范
*
* @author moyin
* @version 1.0.1
* @since 2025-12-17
* @lastModified 2026-01-07
*/
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 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(mockRedisService as any);
// 由于现在使用注入的Redis服务不再直接创建Redis实例
expect(true).toBe(true);
});
it('应该使用自定义Redis配置', () => {
// 创建 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(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: 1766035834340,
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}$/);
});
});
describe('clearCooldown', () => {
it('应该成功清除验证码冷却时间', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(true);
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
});
it('应该处理清除不存在的冷却时间', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(false);
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
});
it('应该处理Redis删除操作错误', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockRejectedValue(new Error('Redis delete failed'));
await expect(service.clearCooldown(email, type)).rejects.toThrow('Redis delete failed');
});
it('应该为不同类型的验证码清除对应的冷却时间', async () => {
const email = 'test@example.com';
const types = [
VerificationCodeType.EMAIL_VERIFICATION,
VerificationCodeType.PASSWORD_RESET,
VerificationCodeType.SMS_VERIFICATION,
];
mockRedis.del.mockResolvedValue(true);
for (const type of types) {
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
}
expect(mockRedis.del).toHaveBeenCalledTimes(types.length);
});
it('应该为不同标识符清除对应的冷却时间', async () => {
const identifiers = ['test1@example.com', 'test2@example.com', '+8613800138000'];
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(true);
for (const identifier of identifiers) {
await service.clearCooldown(identifier, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${identifier}`);
}
expect(mockRedis.del).toHaveBeenCalledTimes(identifiers.length);
});
});
});