package com.mydreamjob.auth; import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.audit.AuditService; 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.stream.Stream; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SmsVerificationAtomicConsumptionTest { @Mock SmsVerificationCodeRepository codes; @Mock RedisTemplate redis; @Mock ValueOperations values; @Mock AuditService audit; @Mock SmsCodeSender sender; @InjectMocks SmsVerificationService service; @BeforeEach void noLockout() { lenient().when(redis.hasKey(anyString())).thenReturn(false); lenient().when(redis.opsForValue()).thenReturn(values); } @Test void successfulCodeUsesConditionalUpdateAndNeverEntitySave() { SmsVerificationCode code = code(false, 0, 5); when(codes.streamLatestUnusedByPhoneAndPurpose("19900000201", "company-register")) .thenReturn(Stream.of(code)); when(codes.consumeIfValid(eq(7L), any())).thenReturn(1); code.setId(7L); assertTrue(service.verifyCode("19900000201", "123456", "company-register")); verify(codes).consumeIfValid(eq(7L), any()); verify(codes, never()).save(any(SmsVerificationCode.class)); } @Test void zeroAffectedRowsIsAConsumedOrInvalidCode() { SmsVerificationCode code = code(false, 0, 5); code.setId(8L); when(codes.streamLatestUnusedByPhoneAndPurpose("19900000202", "company-register")) .thenReturn(Stream.of(code)); when(codes.consumeIfValid(eq(8L), any())).thenReturn(0); assertThatThrownBy(() -> service.verifyCode("19900000202", "123456", "company-register")) .isInstanceOf(BusinessException.class) .extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode()); verify(codes, never()).save(any(SmsVerificationCode.class)); } @Test void unsupportedPurposeIsRejectedBeforeAnyReadOrUpdate() { assertThatThrownBy(() -> service.verifyCode("19900000203", "123456", "arbitrary")) .isInstanceOf(BusinessException.class) .extracting("errorCodeInt").isEqualTo(ErrorCode.BAD_REQUEST.getCode()); verifyNoInteractions(codes); } @Test void attemptsAtLimitAreNotConsumedAgain() { SmsVerificationCode code = code(false, 5, 5); code.setId(9L); when(codes.streamLatestUnusedByPhoneAndPurpose("19900000204", "company-register")) .thenReturn(Stream.of(code)); assertThatThrownBy(() -> service.verifyCode("19900000204", "123456", "company-register")) .isInstanceOf(BusinessException.class) .extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()); verify(codes, never()).consumeIfValid(anyLong(), any()); } private SmsVerificationCode code(boolean used, int attempts, int maxAttempts) { return SmsVerificationCode.builder().id(7L).phone("19900000201").code("123456") .purpose("company-register").used(used).attempts(attempts).maxAttempts(maxAttempts) .createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build(); } }