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()); } }