Files
whale-town-end/src/core/utils/verification/verification.service.spec.ts
angjustinl 6dece752ef test(email, verification, login): 更新测试中的断言内容, 修复测试error.
- 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
2025-12-18 13:29:55 +08:00

590 lines
18 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连接和操作
* - 频率限制机制
* - 错误处理
* - 验证码统计信息
*
* @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}$/);
});
});
});