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;
@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.STUDENT, result.get("role"));
}
@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());
}
}