package com.mydreamjob.auth; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.deletion.AccountDeletion; import com.mydreamjob.deletion.AccountDeletionPhoneHasher; import com.mydreamjob.deletion.AccountDeletionRepository; import com.mydreamjob.deletion.DeletionStatus; 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 java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** * S02 {@link AuthService} 单元测试。 * *

登录链路改为「验证码校验 → 用户解析 → 创建服务端会话」,因此 Mockito 目标 * 由旧的 {@code JwtTokenProvider} 换成 {@link AuthSessionService}; * 原有断言语义(响应字段、自动建号、注销墓碑、验证码用途透传)保持不变。

*/ @ExtendWith(MockitoExtension.class) class AuthServiceTest { private static final Instant FIXED_NOW = Instant.parse("2026-09-14T02:00:00Z"); @Mock private UserRepository userRepository; @Mock private AuthSessionService authSessionService; @Mock private SmsVerificationService smsVerificationService; @Mock private AuditService auditService; @Mock private AccountDeletionRepository accountDeletionRepository; @Mock private AccountDeletionPhoneHasher accountDeletionPhoneHasher; @Mock private Clock clock; @Mock private com.mydreamjob.auth.password.PasswordSetupService passwordSetupService; @InjectMocks private AuthService authService; private void stubSessionIssuance() { when(clock.instant()).thenReturn(FIXED_NOW); when(authSessionService.createSession(any(), any(), any())) .thenReturn(new AuthSessionService.SessionTokens("access-token", "refresh-token", "sid-1")); } @Test void mockLogin_shouldReturnTokenWhenCodeValid() { User user = User.builder() .id(1L).phone("13800000001").nickname("测试用户") .role(User.Role.STUDENT).active(true).verified(true).build(); when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); stubSessionIssuance(); Map result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit"); assertEquals("access-token", result.get("accessToken")); assertEquals("refresh-token", result.get("refreshToken")); assertEquals(1L, result.get("userId")); // expiresIn 契约保持不变(设计 D04) assertEquals(86400L, result.get("expiresIn")); // 登录审计仍写入,且时间来自入口单次捕获的 capturedNow verify(auditService, times(1)).log(eq(1L), eq("LOGIN"), eq("USER"), eq(1L), eq("User logged in"), any(AuditContext.class), eq(LocalDateTime.ofInstant(FIXED_NOW, AuthSessionService.BUSINESS_ZONE))); } @Test void mockLogin_shouldCreateUserWhenNotFound() { when(smsVerificationService.verifyCode("13800000099", "123456", "login")).thenReturn(true); when(userRepository.findByPhone("13800000099")).thenReturn(Optional.empty()); when(userRepository.save(any())).thenAnswer(invocation -> { User u = invocation.getArgument(0); u.setId(99L); return u; }); stubSessionIssuance(); Map result = authService.mockLogin("13800000099", "123456", "login", null, null, null); assertEquals(99L, result.get("userId")); assertEquals(User.Role.UNASSIGNED, result.get("role")); assertEquals(true, result.get("identitySelectionRequired")); } @Test void mockLogin_shouldPropagatePurposeToVerifyCode() { when(smsVerificationService.verifyCode("13800000001", "123456", "password-reset")) .thenReturn(true); User user = User.builder().id(1L).phone("13800000001").role(User.Role.STUDENT).active(true).verified(true).build(); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); stubSessionIssuance(); authService.mockLogin("13800000001", "123456", "password-reset", null, null, null); verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset"); } @Test void mockLogin_shouldThrowWhenCodeInvalid() { when(smsVerificationService.verifyCode(any(), any(), any())) .thenThrow(new BusinessException(ErrorCode.SMS_CODE_INVALID)); assertThrows(BusinessException.class, () -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); // 验证码失败不得创建任何会话 verify(authSessionService, never()).createSession(any(), any(), any()); } @Test void mockLogin_shouldBlockReRegistrationOfDeletedPhone() { when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); when(accountDeletionPhoneHasher.hash("13800000001")).thenReturn("deadbeef"); AccountDeletion tomb = AccountDeletion.builder() .userId(1L).status(DeletionStatus.COMPLETED).deletedPhoneHash("deadbeef").build(); when(accountDeletionRepository.findByDeletedPhoneHash("deadbeef")).thenReturn(Optional.of(tomb)); BusinessException ex = assertThrows(BusinessException.class, () -> authService.mockLogin("13800000001", "123456", "login", null, null, null)); assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt()); // 不得自动创建新用户 verify(userRepository, never()).save(any()); verify(authSessionService, never()).createSession(any(), any(), any()); } @Test void getCurrentUser_shouldThrowWhenUserNotFound() { when(userRepository.findById(999L)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> authService.getCurrentUser(999L)); assertEquals(ErrorCode.USER_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } }