package com.ruoyi.auth.service;
|
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.when;
|
|
import java.util.concurrent.TimeUnit;
|
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.mockito.InjectMocks;
|
import org.mockito.Mock;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
import com.ruoyi.auth.config.UniversalCodeProperties;
|
import com.ruoyi.auth.service.sms.SmsSender;
|
import com.ruoyi.common.core.constant.CacheConstants;
|
import com.ruoyi.common.core.constant.SecurityConstants;
|
import com.ruoyi.common.core.domain.R;
|
import com.ruoyi.common.core.exception.ServiceException;
|
import com.ruoyi.common.redis.service.RedisService;
|
import com.ruoyi.system.api.RemoteAccountUserService;
|
import com.ruoyi.system.api.domain.AccountMember;
|
|
/**
|
* 短信验证码服务单元测试
|
*
|
* 覆盖下发频控、注册查重、校验失败计数与一次性消费等核心分支。
|
*
|
* @author hugray
|
*/
|
@ExtendWith(MockitoExtension.class)
|
class SmsCodeServiceTest
|
{
|
private static final String PHONE = "13800000000";
|
|
private static final String SCENE = "register";
|
|
private static final String LOGIN_SCENE = "login";
|
|
@Mock
|
private RedisService redisService;
|
|
@Mock
|
private SmsSender smsSender;
|
|
@Mock
|
private RemoteAccountUserService remoteAccountUserService;
|
|
@Mock
|
private UniversalCodeProperties universalCodeProperties;
|
|
@InjectMocks
|
private SmsCodeService smsCodeService;
|
|
/** 验证码缓存键:sms_codes:{scene}:{phone} */
|
private String codeKey()
|
{
|
return CacheConstants.SMS_CODE_KEY + SCENE + ":" + PHONE;
|
}
|
|
/** 指定场景的验证码缓存键 */
|
private String codeKey(String scene)
|
{
|
return CacheConstants.SMS_CODE_KEY + scene + ":" + PHONE;
|
}
|
|
/** 错误次数缓存键:sms_code_errs:{scene}:{phone} */
|
private String errorKey()
|
{
|
return CacheConstants.SMS_CODE_ERR_KEY + SCENE + ":" + PHONE;
|
}
|
|
@Test
|
@DisplayName("手机号格式非法时下发应被拒绝")
|
void sendCode_shouldThrow_whenPhoneInvalid()
|
{
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.sendCode("12345", SCENE));
|
assertEquals("请输入正确手机号", ex.getMessage());
|
verify(smsSender, never()).send(anyString(), anyString());
|
}
|
|
@Test
|
@DisplayName("注册场景手机号已注册时应提示前往登录")
|
void sendCode_shouldThrow_whenPhoneRegistered()
|
{
|
AccountMember member = new AccountMember();
|
member.setPhone(PHONE);
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok(member));
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.sendCode(PHONE, SCENE));
|
assertEquals("手机号已注册,请前往登录", ex.getMessage());
|
verify(smsSender, never()).send(anyString(), anyString());
|
}
|
|
@Test
|
@DisplayName("登录场景手机号未注册时应提示先注册")
|
void sendCode_shouldThrow_whenPhoneNotRegisteredForLogin()
|
{
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.sendCode(PHONE, LOGIN_SCENE));
|
assertEquals("该手机号未注册,请先注册", ex.getMessage());
|
verify(smsSender, never()).send(anyString(), anyString());
|
}
|
|
@Test
|
@DisplayName("登录场景手机号已注册时应正常下发")
|
void sendCode_shouldSend_whenLoginSceneAndRegistered()
|
{
|
AccountMember member = new AccountMember();
|
member.setPhone(PHONE);
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok(member));
|
when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
|
|
assertDoesNotThrow(() -> smsCodeService.sendCode(PHONE, LOGIN_SCENE));
|
|
verify(redisService).setCacheObject(eq(codeKey(LOGIN_SCENE)), any(), any(), any(TimeUnit.class));
|
verify(smsSender).send(eq(PHONE), anyString());
|
}
|
|
@Test
|
@DisplayName("30 秒内重复下发应被频控拦截")
|
void sendCode_shouldThrow_whenTooFrequent()
|
{
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
|
when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(false);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.sendCode(PHONE, SCENE));
|
assertEquals("验证码发送过于频繁,请稍后再试", ex.getMessage());
|
verify(smsSender, never()).send(anyString(), anyString());
|
}
|
|
@Test
|
@DisplayName("正常下发应写缓存并调用短信发送器")
|
void sendCode_shouldSend_whenValid()
|
{
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
|
when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
|
|
assertDoesNotThrow(() -> smsCodeService.sendCode(PHONE, SCENE));
|
|
verify(redisService).setCacheObject(eq(codeKey()), any(), any(), any(TimeUnit.class));
|
verify(redisService).deleteObject(errorKey());
|
verify(smsSender).send(eq(PHONE), anyString());
|
}
|
|
@Test
|
@DisplayName("短信发送异常时应提示发送失败")
|
void sendCode_shouldThrow_whenSmsSendFails()
|
{
|
when(remoteAccountUserService.getMemberByPhone(PHONE, SecurityConstants.INNER)).thenReturn(R.ok());
|
when(redisService.setCacheObjectIfAbsent(anyString(), any(), any(), any(TimeUnit.class))).thenReturn(true);
|
doThrow(new RuntimeException("gateway timeout")).when(smsSender).send(anyString(), anyString());
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.sendCode(PHONE, SCENE));
|
assertEquals("短信发送失败", ex.getMessage());
|
}
|
|
@Test
|
@DisplayName("验证码已失效时应提示重新获取")
|
void verifyAndConsume_shouldThrow_whenCodeExpired()
|
{
|
when(redisService.getCacheObject(codeKey())).thenReturn(null);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "123456"));
|
assertEquals("验证码已失效,请重新获取", ex.getMessage());
|
}
|
|
@Test
|
@DisplayName("验证码错误时应累加失败次数")
|
void verifyAndConsume_shouldCountFailure_whenCodeMismatch()
|
{
|
when(redisService.getCacheObject(codeKey())).thenReturn("111111");
|
when(redisService.getCacheObject(errorKey())).thenReturn(null);
|
when(redisService.getExpire(codeKey())).thenReturn(300L);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "222222"));
|
assertEquals("验证码错误", ex.getMessage());
|
verify(redisService).setCacheObject(eq(errorKey()), eq(1), eq(300L), eq(TimeUnit.SECONDS));
|
}
|
|
@Test
|
@DisplayName("验证码错误次数达到上限应作废验证码")
|
void verifyAndConsume_shouldVoidCode_whenTooManyFailures()
|
{
|
when(redisService.getCacheObject(codeKey())).thenReturn("111111");
|
when(redisService.getCacheObject(errorKey())).thenReturn(4);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "222222"));
|
assertEquals("验证码错误次数过多,请重新获取", ex.getMessage());
|
verify(redisService).deleteObject(codeKey());
|
verify(redisService).deleteObject(errorKey());
|
}
|
|
@Test
|
@DisplayName("验证码正确时应一次性消费")
|
void verifyAndConsume_shouldConsume_whenCodeMatch()
|
{
|
when(redisService.getCacheObject(codeKey())).thenReturn("111111");
|
|
assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "111111"));
|
verify(redisService).deleteObject(codeKey());
|
verify(redisService).deleteObject(errorKey());
|
}
|
|
@Test
|
@DisplayName("登录场景命中通用验证码应直接放行且不读缓存不消费")
|
void verifyAndConsume_shouldPass_whenUniversalCodeHitForLogin()
|
{
|
when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
|
|
assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, LOGIN_SCENE, "999999"));
|
verify(redisService, never()).getCacheObject(anyString());
|
verify(redisService, never()).deleteObject(anyString());
|
}
|
|
@Test
|
@DisplayName("注册场景命中通用验证码应直接放行且不读缓存不消费")
|
void verifyAndConsume_shouldPass_whenUniversalCodeHitForRegister()
|
{
|
when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
|
|
assertDoesNotThrow(() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "999999"));
|
verify(redisService, never()).getCacheObject(anyString());
|
verify(redisService, never()).deleteObject(anyString());
|
}
|
|
@Test
|
@DisplayName("非登录注册场景通用验证码不生效")
|
void verifyAndConsume_shouldNotPass_whenUniversalCodeHitForResetScene()
|
{
|
when(universalCodeProperties.getUniversalSmsCode()).thenReturn("999999");
|
when(redisService.getCacheObject(codeKey("reset"))).thenReturn(null);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.verifyAndConsume(PHONE, "reset", "999999"));
|
assertEquals("验证码已失效,请重新获取", ex.getMessage());
|
}
|
|
@Test
|
@DisplayName("未配置通用验证码时不应放行")
|
void verifyAndConsume_shouldNotPass_whenUniversalCodeNotConfigured()
|
{
|
when(universalCodeProperties.getUniversalSmsCode()).thenReturn(null);
|
when(redisService.getCacheObject(codeKey())).thenReturn(null);
|
|
ServiceException ex = assertThrows(ServiceException.class,
|
() -> smsCodeService.verifyAndConsume(PHONE, SCENE, "999999"));
|
assertEquals("验证码已失效,请重新获取", ex.getMessage());
|
}
|
}
|