fix/login-verification-email-template #26

Merged
moyin merged 9 commits from fix/login-verification-email-template into main 2025-12-25 20:57:25 +08:00
2 changed files with 238 additions and 63 deletions
Showing only changes of commit 0192934c66 - Show all commits

View File

@@ -51,6 +51,7 @@ describe('LoginCoreService', () => {
const mockVerificationService = {
generateCode: jest.fn(),
verifyCode: jest.fn(),
clearCooldown: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
@@ -146,6 +147,69 @@ describe('LoginCoreService', () => {
expect(result.isNewUser).toBe(true);
});
it('should register with email and clear cooldown', async () => {
const email = 'test@example.com';
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.create.mockResolvedValue({ ...mockUser, email });
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
emailService.sendWelcomeEmail.mockResolvedValue(undefined);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('hashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email,
email_verification_code: '123456'
});
expect(result.user.email).toBe(email);
expect(result.isNewUser).toBe(true);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
email,
VerificationCodeType.EMAIL_VERIFICATION
);
});
it('should handle cooldown clearing failure gracefully', async () => {
const email = 'test@example.com';
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.create.mockResolvedValue({ ...mockUser, email });
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
emailService.sendWelcomeEmail.mockResolvedValue(undefined);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('hashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email,
email_verification_code: '123456'
});
expect(result.user.email).toBe(email);
expect(result.isNewUser).toBe(true);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
email,
VerificationCodeType.EMAIL_VERIFICATION
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${email}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should validate password strength', async () => {
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {
throw new BadRequestException('密码长度至少8位');
@@ -231,6 +295,59 @@ describe('LoginCoreService', () => {
expect(result).toEqual(mockUser);
});
it('should reset password and clear cooldown', async () => {
const identifier = 'test@example.com';
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
usersService.findByEmail.mockResolvedValue(mockUser);
usersService.update.mockResolvedValue(mockUser);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
const result = await service.resetPassword({
identifier,
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result).toEqual(mockUser);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.PASSWORD_RESET
);
});
it('should handle cooldown clearing failure gracefully during password reset', async () => {
const identifier = 'test@example.com';
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
usersService.findByEmail.mockResolvedValue(mockUser);
usersService.update.mockResolvedValue(mockUser);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.resetPassword({
identifier,
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result).toEqual(mockUser);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.PASSWORD_RESET
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${identifier}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should throw BadRequestException for invalid verification code', async () => {
verificationService.verifyCode.mockResolvedValue(false);
@@ -336,82 +453,75 @@ describe('LoginCoreService', () => {
);
});
it('should successfully login with phone verification code', async () => {
const phoneUser = { ...mockUser, phone: '+8613800138000' };
usersService.findAll.mockResolvedValue([phoneUser]);
verificationService.verifyCode.mockResolvedValue(true);
const result = await service.verificationCodeLogin({
identifier: '+8613800138000',
verificationCode: '123456'
});
expect(result.user).toEqual(phoneUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'+8613800138000',
VerificationCodeType.SMS_VERIFICATION,
'123456'
);
});
it('should reject unverified email user', async () => {
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
})).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录');
});
it('should reject non-existent user', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(service.verificationCodeLogin({
identifier: 'nonexistent@example.com',
verificationCode: '123456'
})).rejects.toThrow('用户不存在,请先注册账户');
});
it('should reject invalid verification code', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(false);
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '999999'
})).rejects.toThrow('验证码验证失败');
});
it('should reject invalid identifier format', async () => {
await expect(service.verificationCodeLogin({
identifier: 'invalid-identifier',
verificationCode: '123456'
})).rejects.toThrow('请提供有效的邮箱或手机号');
});
});
describe('verificationCodeLogin', () => {
it('should successfully login with email verification code', async () => {
it('should successfully login with email verification code and clear cooldown', async () => {
const identifier = 'test@example.com';
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(verifiedUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'test@example.com',
VerificationCodeType.EMAIL_VERIFICATION,
'123456'
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.EMAIL_VERIFICATION
);
});
it('should successfully login with phone verification code and clear cooldown', async () => {
const identifier = '+8613800138000';
const phoneUser = { ...mockUser, phone: identifier };
usersService.findAll.mockResolvedValue([phoneUser]);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
const result = await service.verificationCodeLogin({
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(phoneUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.SMS_VERIFICATION
);
});
it('should handle cooldown clearing failure gracefully during verification code login', async () => {
const identifier = 'test@example.com';
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.verificationCodeLogin({
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(verifiedUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.EMAIL_VERIFICATION
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${identifier}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should successfully login with phone verification code', async () => {
const phoneUser = { ...mockUser, phone: '+8613800138000' };
usersService.findAll.mockResolvedValue([phoneUser]);

View File

@@ -587,4 +587,69 @@ describe('VerificationService', () => {
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);
});
});
});