package com.mydreamjob.auth; import com.mydreamjob.audit.AuditLog; import com.mydreamjob.audit.AuditLogRepository; import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; 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.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.ApplicationContext; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * S01 定向整改 —— 补充回归:不可用发送实现下的事务回滚。 * *

本测试使用真实 Spring 上下文 + 真实 H2 + Flyway + 真实 * {@link SmsVerificationService} 与真实 {@code @Transactional} 代理, * 并在本测试类中把 {@code app.sms.mock.enabled} 覆盖为 {@code false}, * 因此容器装配的是 {@link UnavailableSmsCodeSender}。

* *

测试类本身不加 {@code @Transactional},以保证 * {@code sendCode} 的事务独立提交/回滚,从而真实验证“发送失败不残留可用验证码”。

*/ @SpringBootTest(properties = {"app.sms.mock.enabled=false"}) @ActiveProfiles("integration-test") class SmsUnavailableSenderRollbackIntegrationTest { private static final String PHONE = "19900001234"; private static final String PURPOSE = "login"; @MockBean private RedisTemplate redisTemplate; @Autowired private ApplicationContext applicationContext; @Autowired private SmsVerificationService smsVerificationService; @Autowired private SmsVerificationCodeRepository codeRepository; @Autowired private AuditLogRepository auditLogRepository; @BeforeEach @SuppressWarnings("unchecked") void setUp() { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(anyString())).thenReturn(false); } @Test void containerProvidesExactlyOneUnavailableSenderWhenMockDisabled() { assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class)) .as("mock=false 时必须恰好一个 SmsCodeSender") .hasSize(1); assertThat(applicationContext.getBean(SmsCodeSender.class)) .isInstanceOf(UnavailableSmsCodeSender.class); } @Test void unavailableSenderFailureLeavesNoVerificationCodeRow() { // 清理本测试手机号的既有数据,保证断言只反映本次调用 List pre = codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE); if (!pre.isEmpty()) { codeRepository.deleteAll(pre); } long beforeTotal = codeRepository.count(); LocalDateTime since = LocalDateTime.now().minusMinutes(1); long auditBefore = auditLogRepository.findByCreatedAtAfter(since).stream() .filter(a -> "SMS_SEND".equals(a.getAction())) .count(); assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, "rollback-test", null, null)) .isInstanceOf(BusinessException.class) .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) .isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode())); // 事务已回滚:不得残留该手机号的任何验证码行(不只是数量相等) assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)) .as("发送失败后不得残留可用验证码") .isEmpty(); assertThat(codeRepository.count()).isEqualTo(beforeTotal); // 失败不得产生“发送成功”审计 long auditAfter = auditLogRepository.findByCreatedAtAfter(since).stream() .filter(a -> "SMS_SEND".equals(a.getAction())) .count(); assertThat(auditAfter).isEqualTo(auditBefore); } @Test void unavailableSenderFailureDoesNotReturnFalseSuccess() { assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, null, null, null)) .isInstanceOf(BusinessException.class) .isNotInstanceOf(NumberFormatException.class); } }