package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; 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 org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SmsVerificationServiceTest { @Mock private SmsVerificationCodeRepository codeRepository; @Mock private RedisTemplate redisTemplate; @Mock private ValueOperations valueOperations; @Mock private AuditService auditService; @Mock private SmsCodeSender smsCodeSender; @InjectMocks private SmsVerificationService smsVerificationService; @BeforeEach void setUp() { lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); } @Test void sendCode_shouldSucceed() { when(redisTemplate.hasKey(anyString())).thenReturn(false); when(codeRepository.save(any())).thenReturn(null); assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); verify(codeRepository, times(1)).save(any()); verify(valueOperations, times(1)).set(anyString(), any(), anyLong(), any()); } @Test void sendCode_shouldThrowWhenRateLimited() { when(redisTemplate.hasKey(eq("sms:lockout:13800000001"))).thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, () -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); assertEquals(ErrorCode.SMS_RATE_LIMITED.getCode(), ex.getErrorCodeInt()); } // ---- verifyCode tests ---- @Test void verifyCode_success_withPurpose() { SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); when(redisTemplate.hasKey(anyString())).thenReturn(false); when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) .thenReturn(java.util.stream.Stream.of(code)); when(codeRepository.consumeIfValid(any(), any())).thenReturn(1); assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); verify(codeRepository, times(1)).consumeIfValid(any(), any()); } @Test void verifyCode_replayMustFail_afterUsed() { SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); when(redisTemplate.hasKey(anyString())).thenReturn(false); // 第一次验证通过后 used=true when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) .thenReturn(java.util.stream.Stream.of(code)); when(codeRepository.consumeIfValid(any(), any())).thenReturn(1); // 第一次成功 smsVerificationService.verifyCode("13800000001", "123456", "login"); // 第二次重放:已使用的验证码不应再查到(used=true 的不在查询结果中) when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) .thenReturn(java.util.stream.Stream.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); } @Test void verifyCode_purposeMismatchMustFail() { SmsVerificationCode code = buildCode("13800000001", "123456", "login", false); when(redisTemplate.hasKey(anyString())).thenReturn(false); // 查询 password-reset 用途,但实际只有 login 的记录 when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "password-reset")) .thenReturn(java.util.stream.Stream.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> smsVerificationService.verifyCode("13800000001", "123456", "password-reset")); assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); } @Test void verifyCode_usedCodeMustFail_directly() { SmsVerificationCode code = buildCode("13800000001", "123456", "login", true); when(redisTemplate.hasKey(anyString())).thenReturn(false); when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) .thenReturn(java.util.stream.Stream.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); } @Test void verifyCode_latestPriority_returnsNewestOnly() { LocalDateTime oldTime = LocalDateTime.now().minusMinutes(5); LocalDateTime newTime = LocalDateTime.now().minusMinutes(1); SmsVerificationCode oldCode = SmsVerificationCode.builder() .phone("13800000001").code("999999").purpose("login") .expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(oldTime).build(); SmsVerificationCode newCode = SmsVerificationCode.builder() .phone("13800000001").code("123456").purpose("login") .expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(newTime).build(); when(redisTemplate.hasKey(anyString())).thenReturn(false); // repository 按 createdAt DESC 返回,第一个是最新的 when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login")) .thenReturn(java.util.stream.Stream.of(newCode)); when(codeRepository.consumeIfValid(any(), any())).thenReturn(1); assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login")); // 旧验证码 999999 不应被验证通过 verify(codeRepository, never()).save(oldCode); } @Test void verifyCode_noRecordMustFail() { when(redisTemplate.hasKey(anyString())).thenReturn(false); when(codeRepository.streamLatestUnusedByPhoneAndPurpose(any(), any())) .thenReturn(java.util.stream.Stream.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> smsVerificationService.verifyCode("13800000001", "123456", "login")); assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt()); } private SmsVerificationCode buildCode(String phone, String code, String purpose, boolean used) { return SmsVerificationCode.builder() .phone(phone).code(code).purpose(purpose) .expiredAt(LocalDateTime.now().plusMinutes(5)) .attempts(0).maxAttempts(5).used(used) .createdAt(LocalDateTime.now()).build(); } }