You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

170 lines
7.3 KiB

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<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> 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();
}
}