From 8fc55d170aa0455d30c45172f1100f278a0ac408 Mon Sep 17 00:00:00 2001 From: hyoseok Date: Thu, 9 Apr 2026 17:13:28 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=99=80=20CI?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/test 트리와 테스트 리소스 제거\n- Gradle test 및 integrationTest 설정 제거\n- CI와 deploy workflow에서 테스트 실행 단계 제거 --- .github/workflows/ci.yml | 24 +- .github/workflows/deploy.yml | 25 -- build.gradle | 34 --- .../ScoreRecalculationProcessorTest.java | 226 ---------------- .../api/domain/auth/AuthControllerTest.java | 161 ----------- .../domain/auth/service/AuthServiceTest.java | 193 -------------- .../auth/service/RefreshTokenServiceTest.java | 72 ----- .../api/domain/badge/BadgeControllerTest.java | 68 ----- .../api/domain/badge/BadgeServiceTest.java | 104 -------- .../domain/log/ActivityLogRepositoryIT.java | 150 ----------- .../domain/ranking/RankingControllerTest.java | 83 ------ .../RankingRecalculationServiceTest.java | 50 ---- .../api/domain/user/UserControllerTest.java | 163 ----------- .../api/domain/user/UserRepositoryIT.java | 191 ------------- .../gitranker/api/domain/user/UserTest.java | 209 --------------- .../service/UserPersistenceServiceTest.java | 108 -------- .../user/service/UserRefreshServiceTest.java | 129 --------- .../service/UserRegistrationServiceTest.java | 167 ------------ .../user/vo/ActivityStatisticsTest.java | 159 ----------- .../api/domain/user/vo/RankInfoTest.java | 212 --------------- .../api/domain/user/vo/ScoreTest.java | 160 ----------- .../api/global/auth/jwt/JwtProviderTest.java | 184 ------------- .../api/global/logging/LogContextTest.java | 139 ---------- .../api/global/logging/LogSanitizerTest.java | 50 ---- .../api/global/logging/LoggingFilterTest.java | 123 --------- .../github/GitHubApiErrorHandlerTest.java | 252 ------------------ .../github/token/GitHubTokenPoolTest.java | 97 ------- src/test/resources/application-openapi.yml | 49 ---- 28 files changed, 4 insertions(+), 3578 deletions(-) delete mode 100644 src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/auth/AuthControllerTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/auth/service/RefreshTokenServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/badge/BadgeControllerTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/log/ActivityLogRepositoryIT.java delete mode 100644 src/test/java/com/gitranker/api/domain/ranking/RankingControllerTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/UserControllerTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/UserRepositoryIT.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/UserTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java delete mode 100644 src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java delete mode 100644 src/test/java/com/gitranker/api/global/auth/jwt/JwtProviderTest.java delete mode 100644 src/test/java/com/gitranker/api/global/logging/LogContextTest.java delete mode 100644 src/test/java/com/gitranker/api/global/logging/LogSanitizerTest.java delete mode 100644 src/test/java/com/gitranker/api/global/logging/LoggingFilterTest.java delete mode 100644 src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java delete mode 100644 src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java delete mode 100644 src/test/resources/application-openapi.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b9ff9..01d8bd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,26 +11,10 @@ on: - develop jobs: - verify: - name: Unit and Integration Verification + placeholder: + name: CI Placeholder runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Java 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Run unit tests - run: ./gradlew test - - - name: Run integration tests - run: ./gradlew integrationTest + - name: Verification reset notice + run: echo "Backend automated test verification is temporarily disabled." diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d27cb68..699ee4b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,34 +12,9 @@ env: IMAGE_NAME: git-ranker jobs: - verify: - name: Pre-deploy Verification - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Java 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Run unit tests - run: ./gradlew test - - - name: Run integration tests - run: ./gradlew integrationTest - docker: name: Build & Push Docker Image runs-on: ubuntu-latest - needs: verify steps: - name: Checkout code diff --git a/build.gradle b/build.gradle index 42e938a..b069f3a 100644 --- a/build.gradle +++ b/build.gradle @@ -46,40 +46,6 @@ dependencies { runtimeOnly 'io.micrometer:micrometer-registry-prometheus' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - - testImplementation 'io.projectreactor:reactor-test' - testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.springframework.batch:spring-batch-test' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-testcontainers' - testImplementation 'org.testcontainers:junit-jupiter' - testImplementation 'org.testcontainers:mysql' - - testRuntimeOnly 'com.h2database:h2' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -// 단위 테스트: *IT.java 제외 (Docker 불필요) -tasks.named('test') { - useJUnitPlatform() - exclude '**/*IT.class' -} - -// 통합 테스트: Testcontainers 기반 *IT.java만 별도 lane에서 실행 -// check 라이프사이클에는 연결하지 않고 CI/deploy verification이 명시적으로 호출한다. -tasks.register('integrationTest', Test) { - group = 'verification' - description = 'Runs Testcontainers-backed integration tests.' - useJUnitPlatform() - include '**/*IT.class' - - mustRunAfter tasks.named('test') - - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - - // Docker 29+는 최소 API 1.44를 요구하지만 docker-java 3.4.0은 기본값 1.32로 요청함 - systemProperty 'api.version', '1.44' } tasks.named('processResources') { diff --git a/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java b/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java deleted file mode 100644 index e44b135..0000000 --- a/src/test/java/com/gitranker/api/batch/processor/ScoreRecalculationProcessorTest.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.gitranker.api.batch.processor; - -import com.gitranker.api.batch.strategy.FullActivityUpdateStrategy; -import com.gitranker.api.batch.strategy.IncrementalActivityUpdateStrategy; -import com.gitranker.api.domain.log.ActivityLog; -import com.gitranker.api.domain.log.ActivityLogRepository; -import com.gitranker.api.domain.log.ActivityLogService; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.BusinessException; -import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException; -import com.gitranker.api.global.error.exception.GitHubApiRetryableException; -import com.gitranker.api.infrastructure.github.GitHubActivityService; -import com.gitranker.api.infrastructure.github.dto.GitHubNodeUserResponse; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class ScoreRecalculationProcessorTest { - - @InjectMocks - private ScoreRecalculationProcessor processor; - - @Mock private ActivityLogRepository activityLogRepository; - @Mock private ActivityLogService activityLogService; - @Mock private IncrementalActivityUpdateStrategy incrementalStrategy; - @Mock private FullActivityUpdateStrategy fullStrategy; - @Mock private GitHubActivityService gitHubActivityService; - - private User createUser() { - return User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - } - - @Test - @DisplayName("이전 기준 로그가 없으면 Full 전략을 선택한다") - void should_selectFullStrategy_when_noBaselineLog() { - User user = createUser(); - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())).thenReturn(stats); - - User result = processor.process(user); - - assertThat(result).isNotNull(); - assertThat(result.getTotalScore()).isGreaterThan(0); - verify(fullStrategy).update(eq(user), any()); - verify(incrementalStrategy, never()).update(any(), any()); - } - - @Test - @DisplayName("이전 기준 로그가 있으면 Incremental 전략을 선택한다") - void should_selectIncrementalStrategy_when_baselineLogExists() { - User user = createUser(); - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityLog baselineLog = ActivityLog.empty(user, LocalDate.of(2024, 12, 31)); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.of(baselineLog)); - when(incrementalStrategy.update(eq(user), any())).thenReturn(stats); - - User result = processor.process(user); - - assertThat(result).isNotNull(); - verify(incrementalStrategy).update(eq(user), any()); - verify(fullStrategy, never()).update(any(), any()); - } - - @Test - @DisplayName("GitHubApiRetryableException은 그대로 전파된다") - void should_rethrowRetryableException() { - User user = createUser(); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())) - .thenThrow(new GitHubApiRetryableException(ErrorType.GITHUB_API_TIMEOUT)); - - assertThatThrownBy(() -> processor.process(user)) - .isInstanceOf(GitHubApiRetryableException.class); - } - - @Test - @DisplayName("GITHUB_USER_NOT_FOUND가 아닌 NonRetryableException은 그대로 전파된다") - void should_rethrowNonRetryableException_when_notUserNotFound() { - User user = createUser(); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())) - .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_COLLECT_ACTIVITY_FAILED)); - - assertThatThrownBy(() -> processor.process(user)) - .isInstanceOf(GitHubApiNonRetryableException.class); - } - - @Test - @DisplayName("기타 예외는 BusinessException으로 감싸서 전파된다") - void should_wrapInBusinessException_when_unexpectedError() { - User user = createUser(); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())) - .thenThrow(new RuntimeException("unexpected")); - - assertThatThrownBy(() -> processor.process(user)) - .isInstanceOf(BusinessException.class); - } - - @Nested - @DisplayName("username 변경 fallback 테스트") - class UsernameFallbackTest { - - @Test - @DisplayName("USER_NOT_FOUND 발생 시 nodeId로 현재 username을 조회하여 프로필 갱신 후 재계산한다") - void should_retryWithNewUsername_when_userNotFound() { - User user = createUser(); - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - - // 첫 번째 호출: USER_NOT_FOUND, 두 번째 호출(재시도): 성공 - when(fullStrategy.update(eq(user), any())) - .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)) - .thenReturn(stats); - - GitHubNodeUserResponse nodeResponse = new GitHubNodeUserResponse( - new GitHubNodeUserResponse.Data( - new GitHubNodeUserResponse.Node("node1", "newusername", "new@email.com", "https://new-avatar.png"), - null - ) - ); - when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(nodeResponse); - - User result = processor.process(user); - - assertThat(result).isNotNull(); - assertThat(result.getUsername()).isEqualTo("newusername"); - assertThat(result.getEmail()).isEqualTo("new@email.com"); - assertThat(result.getProfileImage()).isEqualTo("https://new-avatar.png"); - assertThat(result.getTotalScore()).isGreaterThan(0); - verify(fullStrategy, times(2)).update(eq(user), any()); - verify(gitHubActivityService).fetchUserByNodeId("node1"); - } - - @Test - @DisplayName("nodeId 조회 결과에 사용자 정보가 없으면 USER_NOT_FOUND 예외를 전파한다") - void should_throwException_when_nodeIdLookupReturnsNoUser() { - User user = createUser(); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())) - .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)); - - GitHubNodeUserResponse emptyResponse = new GitHubNodeUserResponse( - new GitHubNodeUserResponse.Data(null, null) - ); - when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(emptyResponse); - - assertThatThrownBy(() -> processor.process(user)) - .isInstanceOf(GitHubApiNonRetryableException.class); - - verify(gitHubActivityService).fetchUserByNodeId("node1"); - } - - @Test - @DisplayName("username 변경 복구 후 재시도에서도 실패하면 예외를 전파한다") - void should_throwException_when_retryAlsoFails() { - User user = createUser(); - - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(activityLogRepository.findTopByUserAndActivityDateLessThanOrderByActivityDateDesc(eq(user), any())) - .thenReturn(Optional.empty()); - when(fullStrategy.update(eq(user), any())) - .thenThrow(new GitHubApiNonRetryableException(ErrorType.GITHUB_USER_NOT_FOUND)); - - GitHubNodeUserResponse nodeResponse = new GitHubNodeUserResponse( - new GitHubNodeUserResponse.Data( - new GitHubNodeUserResponse.Node("node1", "newusername", "new@email.com", "https://new-avatar.png"), - null - ) - ); - when(gitHubActivityService.fetchUserByNodeId("node1")).thenReturn(nodeResponse); - - assertThatThrownBy(() -> processor.process(user)) - .isInstanceOf(GitHubApiNonRetryableException.class); - - verify(gitHubActivityService).fetchUserByNodeId("node1"); - verify(fullStrategy, times(2)).update(eq(user), any()); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/auth/AuthControllerTest.java b/src/test/java/com/gitranker/api/domain/auth/AuthControllerTest.java deleted file mode 100644 index b5cacb2..0000000 --- a/src/test/java/com/gitranker/api/domain/auth/AuthControllerTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.gitranker.api.domain.auth; - -import com.gitranker.api.domain.auth.service.AuthService; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.global.auth.CustomOAuth2UserService; -import com.gitranker.api.global.auth.OAuth2AuthenticationSuccessHandler; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import com.gitranker.api.global.config.SecurityConfig; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.global.util.TimeUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import jakarta.servlet.http.Cookie; -import java.util.Collections; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(AuthController.class) -@Import({SecurityConfig.class, com.gitranker.api.global.auth.CustomAuthenticationEntryPoint.class}) -@TestPropertySource(properties = { - "app.cors.allowed-origins=http://localhost:3000" -}) -class AuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean private AuthService authService; - @MockitoBean private JwtProvider jwtProvider; - @MockitoBean private UserRepository userRepository; - @MockitoBean private CustomOAuth2UserService customOAuth2UserService; - @MockitoBean private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - @MockitoBean private ClientRegistrationRepository clientRegistrationRepository; - @MockitoBean private TimeUtils timeUtils; - @MockitoBean private BusinessMetrics businessMetrics; - - private UsernamePasswordAuthenticationToken createAuthentication(User user) { - return new UsernamePasswordAuthenticationToken( - user, "", - Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey())) - ); - } - - private User createTestUser() { - return User.builder() - .githubId(1L) - .nodeId("node-1") - .username("testuser") - .email("test@example.com") - .profileImage("https://example.com/avatar.png") - .role(Role.USER) - .build(); - } - - @Nested - @DisplayName("GET /api/v1/auth/me") - class Me { - - @Test - @DisplayName("인증된 사용자면 200과 사용자 정보를 반환한다") - void should_return200_when_authenticated() throws Exception { - User user = createTestUser(); - - mockMvc.perform(get("/api/v1/auth/me") - .with(authentication(createAuthentication(user)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.username").value("testuser")) - .andExpect(jsonPath("$.data.role").value("USER")); - } - - @Test - @DisplayName("인증되지 않으면 401을 반환한다") - void should_return401_when_notAuthenticated() throws Exception { - mockMvc.perform(get("/api/v1/auth/me")) - .andExpect(status().isUnauthorized()); - } - } - - @Nested - @DisplayName("POST /api/v1/auth/refresh") - class Refresh { - - @Test - @DisplayName("유효한 refreshToken 쿠키가 있으면 200을 반환한다") - void should_return200_when_validRefreshTokenCookie() throws Exception { - mockMvc.perform(post("/api/v1/auth/refresh") - .cookie(new Cookie("refreshToken", "valid-token"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")); - - verify(authService).refreshAccessToken(eq("valid-token"), any()); - } - } - - @Nested - @DisplayName("POST /api/v1/auth/logout") - class Logout { - - @Test - @DisplayName("인증된 사용자면 200을 반환한다") - void should_return200_when_authenticated() throws Exception { - User user = createTestUser(); - - mockMvc.perform(post("/api/v1/auth/logout") - .with(authentication(createAuthentication(user))) - .cookie(new Cookie("refreshToken", "valid-token"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")); - } - - @Test - @DisplayName("인증되지 않으면 401을 반환한다") - void should_return401_when_notAuthenticated() throws Exception { - mockMvc.perform(post("/api/v1/auth/logout")) - .andExpect(status().isUnauthorized()); - } - } - - @Nested - @DisplayName("POST /api/v1/auth/logout/all") - class LogoutAll { - - @Test - @DisplayName("인증된 사용자면 200을 반환한다") - void should_return200_when_authenticated() throws Exception { - User user = createTestUser(); - - mockMvc.perform(post("/api/v1/auth/logout/all") - .with(authentication(createAuthentication(user)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")); - } - - @Test - @DisplayName("인증되지 않으면 401을 반환한다") - void should_return401_when_notAuthenticated() throws Exception { - mockMvc.perform(post("/api/v1/auth/logout/all")) - .andExpect(status().isUnauthorized()); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java deleted file mode 100644 index ec68fa5..0000000 --- a/src/test/java/com/gitranker/api/domain/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.gitranker.api.domain.auth.service; - -import com.gitranker.api.domain.auth.RefreshToken; -import com.gitranker.api.domain.auth.RefreshTokenRepository; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.global.auth.AuthCookieManager; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.BusinessException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @InjectMocks - private AuthService authService; - - @Mock private RefreshTokenRepository refreshTokenRepository; - @Mock private RefreshTokenService refreshTokenService; - @Mock private JwtProvider jwtProvider; - @Mock private AuthCookieManager authCookieManager; - - private RefreshToken createValidRefreshToken(User user) { - return RefreshToken.builder() - .token("valid-token") - .user(user) - .expiresAt(LocalDateTime.now().plusDays(7)) - .build(); - } - - private RefreshToken createExpiredRefreshToken(User user) { - return RefreshToken.builder() - .token("expired-token") - .user(user) - .expiresAt(LocalDateTime.now().minusDays(1)) - .build(); - } - - @Nested - @DisplayName("refreshAccessToken") - class RefreshAccessToken { - - @Test - @DisplayName("유효한 토큰이면 새 Access/Refresh 토큰을 발급한다") - void should_issueNewTokens_when_validRefreshToken() { - User user = mock(User.class); - when(user.getUsername()).thenReturn("testuser"); - when(user.getRole()).thenReturn(Role.USER); - RefreshToken refreshToken = createValidRefreshToken(user); - HttpServletResponse response = mock(HttpServletResponse.class); - - when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken)); - when(jwtProvider.createAccessToken("testuser", Role.USER)).thenReturn("new-access-token"); - when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh-token"); - - authService.refreshAccessToken("valid-token", response); - - verify(authCookieManager).addAccessTokenCookie(response, "new-access-token"); - verify(authCookieManager).addRefreshTokenCookie(response, "new-refresh-token"); - } - - @Test - @DisplayName("존재하지 않는 토큰이면 INVALID_REFRESH_TOKEN 예외가 발생한다") - void should_throwInvalidToken_when_tokenNotFound() { - when(refreshTokenRepository.findByToken("unknown")).thenReturn(Optional.empty()); - HttpServletResponse response = mock(HttpServletResponse.class); - - assertThatThrownBy(() -> authService.refreshAccessToken("unknown", response)) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.INVALID_REFRESH_TOKEN)); - } - - @Test - @DisplayName("만료된 토큰이면 삭제 후 EXPIRED_REFRESH_TOKEN 예외가 발생한다") - void should_deleteAndThrow_when_tokenExpired() { - User user = mock(User.class); - RefreshToken expiredToken = createExpiredRefreshToken(user); - HttpServletResponse response = mock(HttpServletResponse.class); - - when(refreshTokenRepository.findByToken("expired-token")).thenReturn(Optional.of(expiredToken)); - - assertThatThrownBy(() -> authService.refreshAccessToken("expired-token", response)) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.EXPIRED_REFRESH_TOKEN)); - - verify(refreshTokenRepository).delete(expiredToken); - } - } - - @Nested - @DisplayName("logout") - class Logout { - - @Test - @DisplayName("본인의 토큰으로 로그아웃하면 토큰을 삭제하고 쿠키를 정리한다") - void should_deleteTokenAndClearCookies_when_validLogout() { - User user = mock(User.class); - when(user.getId()).thenReturn(1L); - when(user.getUsername()).thenReturn("testuser"); - RefreshToken refreshToken = createValidRefreshToken(user); - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - - when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken)); - when(request.getSession(false)).thenReturn(null); - - authService.logout(user, "valid-token", request, response); - - verify(refreshTokenRepository).deleteByToken("valid-token"); - verify(authCookieManager).clearAccessTokenCookie(response); - verify(authCookieManager).clearRefreshTokenCookie(response); - } - - @Test - @DisplayName("다른 사용자의 토큰으로 로그아웃하면 FORBIDDEN 예외가 발생한다") - void should_throwForbidden_when_logoutWithOtherUsersToken() { - User currentUser = mock(User.class); - when(currentUser.getId()).thenReturn(1L); - User otherUser = mock(User.class); - when(otherUser.getId()).thenReturn(2L); - RefreshToken otherToken = createValidRefreshToken(otherUser); - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - - when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(otherToken)); - - assertThatThrownBy(() -> authService.logout(currentUser, "valid-token", request, response)) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.FORBIDDEN)); - } - - @Test - @DisplayName("세션이 있으면 무효화한다") - void should_invalidateSession_when_sessionExists() { - User user = mock(User.class); - when(user.getId()).thenReturn(1L); - when(user.getUsername()).thenReturn("testuser"); - RefreshToken refreshToken = createValidRefreshToken(user); - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - HttpSession session = mock(HttpSession.class); - - when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken)); - when(request.getSession(false)).thenReturn(session); - - authService.logout(user, "valid-token", request, response); - - verify(session).invalidate(); - } - } - - @Nested - @DisplayName("logoutAll") - class LogoutAll { - - @Test - @DisplayName("모든 토큰을 삭제하고 쿠키를 정리한다") - void should_deleteAllTokensAndClearCookies() { - User user = mock(User.class); - when(user.getUsername()).thenReturn("testuser"); - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - - when(request.getSession(false)).thenReturn(null); - - authService.logoutAll(user, request, response); - - verify(refreshTokenRepository).deleteAllByUser(user); - verify(authCookieManager).clearAccessTokenCookie(response); - verify(authCookieManager).clearRefreshTokenCookie(response); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/auth/service/RefreshTokenServiceTest.java b/src/test/java/com/gitranker/api/domain/auth/service/RefreshTokenServiceTest.java deleted file mode 100644 index f5b0a93..0000000 --- a/src/test/java/com/gitranker/api/domain/auth/service/RefreshTokenServiceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.gitranker.api.domain.auth.service; - -import com.gitranker.api.domain.auth.RefreshToken; -import com.gitranker.api.domain.auth.RefreshTokenRepository; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class RefreshTokenServiceTest { - - @InjectMocks - private RefreshTokenService refreshTokenService; - - @Mock private RefreshTokenRepository refreshTokenRepository; - @Mock private JwtProvider jwtProvider; - - private User createUser() { - return User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .role(Role.USER) - .build(); - } - - @Test - @DisplayName("토큰 발급 시 기존 토큰을 삭제하고 새 토큰을 저장한다") - void should_deleteOldAndSaveNew_when_issuingToken() { - User user = createUser(); - when(jwtProvider.createRefreshToken()).thenReturn("new-token-value"); - when(jwtProvider.calculateRefreshTokenExpiry()).thenReturn(LocalDateTime.now().plusDays(7)); - - String tokenValue = refreshTokenService.issueRefreshToken(user); - - assertThat(tokenValue).isEqualTo("new-token-value"); - verify(refreshTokenRepository).deleteAllByUser(user); - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("저장되는 토큰에 올바른 사용자와 만료 시간이 설정된다") - void should_setCorrectUserAndExpiry_when_saving() { - User user = createUser(); - LocalDateTime expiry = LocalDateTime.now().plusDays(7); - when(jwtProvider.createRefreshToken()).thenReturn("token-123"); - when(jwtProvider.calculateRefreshTokenExpiry()).thenReturn(expiry); - - refreshTokenService.issueRefreshToken(user); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); - verify(refreshTokenRepository).save(captor.capture()); - - RefreshToken savedToken = captor.getValue(); - assertThat(savedToken.getToken()).isEqualTo("token-123"); - assertThat(savedToken.getUser()).isEqualTo(user); - assertThat(savedToken.getExpiresAt()).isEqualTo(expiry); - } -} diff --git a/src/test/java/com/gitranker/api/domain/badge/BadgeControllerTest.java b/src/test/java/com/gitranker/api/domain/badge/BadgeControllerTest.java deleted file mode 100644 index dde4bf1..0000000 --- a/src/test/java/com/gitranker/api/domain/badge/BadgeControllerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.gitranker.api.domain.badge; - -import com.gitranker.api.domain.user.Tier; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.global.auth.CustomOAuth2UserService; -import com.gitranker.api.global.auth.OAuth2AuthenticationSuccessHandler; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import com.gitranker.api.global.config.SecurityConfig; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.global.util.TimeUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(BadgeController.class) -@Import({SecurityConfig.class, com.gitranker.api.global.auth.CustomAuthenticationEntryPoint.class}) -@TestPropertySource(properties = { - "app.cors.allowed-origins=http://localhost:3000" -}) -class BadgeControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean private BadgeService badgeService; - @MockitoBean private JwtProvider jwtProvider; - @MockitoBean private UserRepository userRepository; - @MockitoBean private CustomOAuth2UserService customOAuth2UserService; - @MockitoBean private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - @MockitoBean private ClientRegistrationRepository clientRegistrationRepository; - @MockitoBean private TimeUtils timeUtils; - @MockitoBean private BusinessMetrics businessMetrics; - - @Test - @DisplayName("nodeId로 뱃지를 요청하면 SVG를 반환한다") - void should_returnSvg_when_validNodeId() throws Exception { - String svgContent = "test badge"; - when(badgeService.generateBadge("node-123")).thenReturn(svgContent); - - mockMvc.perform(get("/api/v1/badges/node-123")) - .andExpect(status().isOk()) - .andExpect(content().contentTypeCompatibleWith("image/svg+xml")) - .andExpect(content().string(svgContent)) - .andExpect(header().exists("Cache-Control")); - } - - @Test - @DisplayName("티어별 뱃지를 요청하면 SVG를 반환한다") - void should_returnSvg_when_validTier() throws Exception { - String svgContent = "gold badge"; - when(badgeService.generateBadgeByTier(Tier.GOLD)).thenReturn(svgContent); - - mockMvc.perform(get("/api/v1/badges/GOLD/badge")) - .andExpect(status().isOk()) - .andExpect(content().contentTypeCompatibleWith("image/svg+xml")) - .andExpect(content().string(svgContent)); - } -} diff --git a/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java b/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java deleted file mode 100644 index 965fce2..0000000 --- a/src/test/java/com/gitranker/api/domain/badge/BadgeServiceTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.gitranker.api.domain.badge; - -import com.gitranker.api.domain.log.ActivityLog; -import com.gitranker.api.domain.log.ActivityLogRepository; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.Tier; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.BusinessException; -import com.gitranker.api.global.metrics.BusinessMetrics; -import org.junit.jupiter.api.DisplayName; -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.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class BadgeServiceTest { - - @InjectMocks - private BadgeService badgeService; - - @Mock private UserRepository userRepository; - @Mock private ActivityLogRepository activityLogRepository; - @Mock private SvgBadgeRenderer svgBadgeRenderer; - @Mock private BusinessMetrics businessMetrics; - - @Test - @DisplayName("사용자가 존재하면 SVG 뱃지를 생성한다") - void should_generateBadge_when_userExists() { - User user = User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - ActivityLog activityLog = ActivityLog.empty(user, LocalDate.now()); - - when(userRepository.findByNodeId("node1")).thenReturn(Optional.of(user)); - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(activityLog); - when(svgBadgeRenderer.render(eq(user), eq(Tier.IRON), eq(activityLog))).thenReturn("badge"); - - String badge = badgeService.generateBadge("node1"); - - assertThat(badge).isEqualTo("badge"); - verify(businessMetrics).incrementBadgeViews(); - } - - @Test - @DisplayName("사용자가 존재하지 않으면 USER_NOT_FOUND 예외가 발생한다") - void should_throwUserNotFound_when_nodeIdInvalid() { - when(userRepository.findByNodeId("invalid")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> badgeService.generateBadge("invalid")) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.USER_NOT_FOUND)); - } - - @Test - @DisplayName("활동 로그가 없으면 빈 로그로 뱃지를 생성한다") - void should_useEmptyLog_when_noActivityLogExists() { - User user = User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - - when(userRepository.findByNodeId("node1")).thenReturn(Optional.of(user)); - when(activityLogRepository.getTopByUserOrderByActivityDateDesc(user)).thenReturn(null); - when(svgBadgeRenderer.render(eq(user), eq(Tier.IRON), any(ActivityLog.class))).thenReturn(""); - - String badge = badgeService.generateBadge("node1"); - - assertThat(badge).isNotNull(); - verify(svgBadgeRenderer).render(eq(user), eq(Tier.IRON), any(ActivityLog.class)); - } - - @Test - @DisplayName("티어별 미리보기 뱃지를 생성한다") - void should_generatePreviewBadge_when_tierProvided() { - when(svgBadgeRenderer.render(any(User.class), eq(Tier.DIAMOND), any(ActivityLog.class))) - .thenReturn("diamond"); - - String badge = badgeService.generateBadgeByTier(Tier.DIAMOND); - - assertThat(badge).isEqualTo("diamond"); - } -} diff --git a/src/test/java/com/gitranker/api/domain/log/ActivityLogRepositoryIT.java b/src/test/java/com/gitranker/api/domain/log/ActivityLogRepositoryIT.java deleted file mode 100644 index c49c5fd..0000000 --- a/src/test/java/com/gitranker/api/domain/log/ActivityLogRepositoryIT.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.gitranker.api.domain.log; - -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Testcontainers -@TestPropertySource(properties = { - "spring.jpa.hibernate.ddl-auto=create", - "spring.batch.jdbc.initialize-schema=never" -}) -class ActivityLogRepositoryIT { - - @Container - @ServiceConnection - static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") - .withDatabaseName("gitranker_test"); - - @Autowired - private ActivityLogRepository activityLogRepository; - - @Autowired - private UserRepository userRepository; - - private User savedUser; - - @BeforeEach - void setUp() { - activityLogRepository.deleteAll(); - userRepository.deleteAll(); - - savedUser = userRepository.save(User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build()); - } - - @Test - @DisplayName("여러 로그 중 가장 최근 날짜의 로그를 조회한다") - void should_findLatestLog_when_multipleLogsExist() { - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1); - - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1))); - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 2))); - ActivityLog latestLog = activityLogRepository.save( - ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 3))); - - ActivityLog found = activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser); - - assertThat(found).isNotNull(); - assertThat(found.getActivityDate()).isEqualTo(LocalDate.of(2025, 1, 3)); - } - - @Test - @DisplayName("로그가 없으면 null을 반환한다") - void should_returnNull_when_noLogExists() { - ActivityLog found = activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser); - - assertThat(found).isNull(); - } - - @Test - @DisplayName("특정 날짜 이전의 가장 최근 로그를 조회한다") - void should_findBaselineLog_when_logBeforeDateExists() { - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1); - - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1))); - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 10))); - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 20))); - - // 1월 15일 이전의 가장 최근 로그 → 1월 10일 - Optional found = activityLogRepository - .findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( - savedUser, LocalDate.of(2025, 1, 15)); - - assertThat(found).isPresent(); - assertThat(found.get().getActivityDate()).isEqualTo(LocalDate.of(2025, 1, 10)); - } - - @Test - @DisplayName("기준 날짜 이전에 로그가 없으면 빈 Optional을 반환한다") - void should_returnEmpty_when_noLogBeforeDateExists() { - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1); - - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 6, 1))); - - // 2025년 1월 이전 → 없음 - Optional found = activityLogRepository - .findTopByUserAndActivityDateLessThanOrderByActivityDateDesc( - savedUser, LocalDate.of(2025, 1, 1)); - - assertThat(found).isEmpty(); - } - - @Test - @DisplayName("사용자와 날짜로 정확히 일치하는 로그를 조회한다") - void should_findLog_when_userAndDateMatch() { - ActivityStatistics stats = ActivityStatistics.of(50, 10, 5, 3, 8); - ActivityStatistics diff = ActivityStatistics.of(5, 1, 1, 0, 2); - - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 3, 15))); - - Optional found = activityLogRepository.findByUserAndActivityDate( - savedUser, LocalDate.of(2025, 3, 15)); - - assertThat(found).isPresent(); - assertThat(found.get().getCommitCount()).isEqualTo(50); - assertThat(found.get().getIssueCount()).isEqualTo(10); - } - - @Test - @DisplayName("사용자의 모든 로그를 삭제한다") - void should_deleteAllLogs_when_deleteAllByUserCalled() { - ActivityStatistics stats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityStatistics diff = ActivityStatistics.of(5, 1, 0, 0, 1); - - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 1))); - activityLogRepository.save(ActivityLog.of(savedUser, stats, diff, LocalDate.of(2025, 1, 2))); - - activityLogRepository.deleteAllByUser(savedUser); - - assertThat(activityLogRepository.getTopByUserOrderByActivityDateDesc(savedUser)).isNull(); - } -} diff --git a/src/test/java/com/gitranker/api/domain/ranking/RankingControllerTest.java b/src/test/java/com/gitranker/api/domain/ranking/RankingControllerTest.java deleted file mode 100644 index 5355067..0000000 --- a/src/test/java/com/gitranker/api/domain/ranking/RankingControllerTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.gitranker.api.domain.ranking; - -import com.gitranker.api.domain.ranking.dto.RankingList; -import com.gitranker.api.domain.user.Tier; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.global.auth.CustomOAuth2UserService; -import com.gitranker.api.global.auth.OAuth2AuthenticationSuccessHandler; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import com.gitranker.api.global.config.SecurityConfig; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.global.util.TimeUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(RankingController.class) -@Import({SecurityConfig.class, com.gitranker.api.global.auth.CustomAuthenticationEntryPoint.class}) -@TestPropertySource(properties = { - "app.cors.allowed-origins=http://localhost:3000" -}) -class RankingControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean private RankingService rankingService; - @MockitoBean private JwtProvider jwtProvider; - @MockitoBean private UserRepository userRepository; - @MockitoBean private CustomOAuth2UserService customOAuth2UserService; - @MockitoBean private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - @MockitoBean private ClientRegistrationRepository clientRegistrationRepository; - @MockitoBean private TimeUtils timeUtils; - @MockitoBean private BusinessMetrics businessMetrics; - - private RankingList createEmptyRankingList() { - return new RankingList( - Collections.emptyList(), - new RankingList.PageInfo(0, 20, 0, 0, true, true) - ); - } - - @Test - @DisplayName("기본 파라미터로 요청하면 200과 랭킹 리스트를 반환한다") - void should_return200_when_defaultParams() throws Exception { - when(rankingService.getRankingList(eq(0), isNull())).thenReturn(createEmptyRankingList()); - - mockMvc.perform(get("/api/v1/ranking")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.rankings").isArray()); - } - - @Test - @DisplayName("티어 필터를 지정하면 200을 반환한다") - void should_return200_when_tierProvided() throws Exception { - when(rankingService.getRankingList(eq(0), eq(Tier.GOLD))).thenReturn(createEmptyRankingList()); - - mockMvc.perform(get("/api/v1/ranking").param("tier", "GOLD")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")); - } - - @Test - @DisplayName("음수 page이면 400을 반환한다") - void should_return400_when_negativePage() throws Exception { - mockMvc.perform(get("/api/v1/ranking").param("page", "-1")) - .andExpect(status().isBadRequest()); - } -} diff --git a/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java b/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java deleted file mode 100644 index e4c653c..0000000 --- a/src/test/java/com/gitranker/api/domain/ranking/RankingRecalculationServiceTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.gitranker.api.domain.ranking; - -import com.gitranker.api.domain.user.UserRepository; -import org.junit.jupiter.api.DisplayName; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class RankingRecalculationServiceTest { - - @InjectMocks - private RankingRecalculationService rankingRecalculationService; - - @Mock private UserRepository userRepository; - @Mock private RankingService rankingService; - - @Test - @DisplayName("첫 호출 시 랭킹을 재산정한다") - void should_recalculate_when_calledFirstTime() { - boolean result = rankingRecalculationService.recalculateIfNeeded(); - - assertThat(result).isTrue(); - verify(userRepository).bulkUpdateRanking(); - verify(rankingService).evictRankingCache(); - } - - @Test - @DisplayName("연속 호출 시 디바운스로 두 번째 호출을 건너뛴다") - void should_skipRecalculation_when_calledImmediatelyAgain() { - rankingRecalculationService.recalculateIfNeeded(); // 첫 번째: 실행 - boolean result = rankingRecalculationService.recalculateIfNeeded(); // 두 번째: 건너뜀 - - assertThat(result).isFalse(); - verify(userRepository, times(1)).bulkUpdateRanking(); // 1번만 호출 - } - - @Test - @DisplayName("재산정 완료 후 랭킹 캐시를 무효화한다") - void should_evictCache_when_recalculationCompleted() { - rankingRecalculationService.recalculateIfNeeded(); - - verify(rankingService).evictRankingCache(); - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/UserControllerTest.java b/src/test/java/com/gitranker/api/domain/user/UserControllerTest.java deleted file mode 100644 index f8ad16f..0000000 --- a/src/test/java/com/gitranker/api/domain/user/UserControllerTest.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.gitranker.api.domain.user; - -import com.gitranker.api.domain.user.dto.RegisterUserResponse; -import com.gitranker.api.domain.user.service.UserDeletionService; -import com.gitranker.api.domain.user.service.UserQueryService; -import com.gitranker.api.domain.user.service.UserRefreshService; -import com.gitranker.api.global.auth.CustomOAuth2UserService; -import com.gitranker.api.global.auth.OAuth2AuthenticationSuccessHandler; -import com.gitranker.api.global.auth.jwt.JwtProvider; -import com.gitranker.api.global.config.SecurityConfig; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.global.util.TimeUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDateTime; -import java.util.Collections; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(UserController.class) -@Import({SecurityConfig.class, com.gitranker.api.global.auth.CustomAuthenticationEntryPoint.class}) -@TestPropertySource(properties = { - "app.cors.allowed-origins=http://localhost:3000" -}) -class UserControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean private UserQueryService userQueryService; - @MockitoBean private UserRefreshService userRefreshService; - @MockitoBean private UserDeletionService userDeletionService; - @MockitoBean private JwtProvider jwtProvider; - @MockitoBean private UserRepository userRepository; - @MockitoBean private CustomOAuth2UserService customOAuth2UserService; - @MockitoBean private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - @MockitoBean private ClientRegistrationRepository clientRegistrationRepository; - @MockitoBean private TimeUtils timeUtils; - @MockitoBean private BusinessMetrics businessMetrics; - - private UsernamePasswordAuthenticationToken createAuthentication(User user) { - return new UsernamePasswordAuthenticationToken( - user, "", - Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey())) - ); - } - - private User createTestUser(String username) { - return User.builder() - .githubId(1L) - .nodeId("node-1") - .username(username) - .role(Role.USER) - .build(); - } - - private RegisterUserResponse createResponse(String username) { - return new RegisterUserResponse( - 1L, 1L, "node-1", username, null, null, Role.USER, - LocalDateTime.now(), LocalDateTime.now(), - 1000, 1, Tier.SILVER, 50.0, - 100, 10, 5, 3, 8, - 10, 1, 1, 0, 2, - false - ); - } - - @Nested - @DisplayName("GET /api/v1/users/{username}") - class GetUser { - - @Test - @DisplayName("유효한 username이면 200과 사용자 정보를 반환한다") - void should_return200_when_validUsername() throws Exception { - when(userQueryService.findByUsername("testuser")).thenReturn(createResponse("testuser")); - - mockMvc.perform(get("/api/v1/users/testuser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.username").value("testuser")); - } - - @Test - @DisplayName("허용되지 않는 username 형식이면 400을 반환한다") - void should_return400_when_invalidUsernameFormat() throws Exception { - mockMvc.perform(get("/api/v1/users/invalid--user")) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("POST /api/v1/users/{username}/refresh") - class RefreshUser { - - @Test - @DisplayName("본인 계정이면 200과 갱신된 정보를 반환한다") - void should_return200_when_sameUser() throws Exception { - User user = createTestUser("testuser"); - when(userRefreshService.refresh("testuser")).thenReturn(createResponse("testuser")); - - mockMvc.perform(post("/api/v1/users/testuser/refresh") - .with(authentication(createAuthentication(user)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result").value("SUCCESS")); - - verify(userRefreshService).refresh("testuser"); - } - - @Test - @DisplayName("다른 사용자 계정이면 403을 반환한다") - void should_return403_when_differentUser() throws Exception { - User user = createTestUser("otheruser"); - - mockMvc.perform(post("/api/v1/users/testuser/refresh") - .with(authentication(createAuthentication(user)))) - .andExpect(status().isForbidden()); - } - - @Test - @DisplayName("인증되지 않으면 401을 반환한다") - void should_return401_when_notAuthenticated() throws Exception { - mockMvc.perform(post("/api/v1/users/testuser/refresh")) - .andExpect(status().isUnauthorized()); - } - } - - @Nested - @DisplayName("DELETE /api/v1/users/me") - class DeleteMyAccount { - - @Test - @DisplayName("인증된 사용자면 204를 반환한다") - void should_return204_when_authenticated() throws Exception { - User user = createTestUser("testuser"); - - mockMvc.perform(delete("/api/v1/users/me") - .with(authentication(createAuthentication(user)))) - .andExpect(status().isNoContent()); - } - - @Test - @DisplayName("인증되지 않으면 401을 반환한다") - void should_return401_when_notAuthenticated() throws Exception { - mockMvc.perform(delete("/api/v1/users/me")) - .andExpect(status().isUnauthorized()); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/UserRepositoryIT.java b/src/test/java/com/gitranker/api/domain/user/UserRepositoryIT.java deleted file mode 100644 index 814bb58..0000000 --- a/src/test/java/com/gitranker/api/domain/user/UserRepositoryIT.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.gitranker.api.domain.user; - -import com.gitranker.api.domain.user.vo.Score; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Testcontainers -@TestPropertySource(properties = { - "spring.jpa.hibernate.ddl-auto=create", - "spring.batch.jdbc.initialize-schema=never" -}) -class UserRepositoryIT { - - @Container - @ServiceConnection - static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") - .withDatabaseName("gitranker_test"); - - @Autowired - private UserRepository userRepository; - - @BeforeEach - void setUp() { - userRepository.deleteAll(); - } - - private User createAndSaveUser(String username, String nodeId, Long githubId, int score) { - User user = User.builder() - .githubId(githubId) - .nodeId(nodeId) - .username(username) - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - user.updateScore(Score.of(score)); - return userRepository.save(user); - } - - @Test - @DisplayName("bulkUpdateRanking 호출 시 점수 기준으로 ranking, percentile, tier가 올바르게 계산된다") - void should_updateAllRankings_when_bulkUpdateRankingCalled() { - // 3명의 사용자: 점수 3000, 1500, 100 - createAndSaveUser("high", "node-high", 1L, 3000); - createAndSaveUser("mid", "node-mid", 2L, 1500); - createAndSaveUser("low", "node-low", 3L, 100); - - userRepository.bulkUpdateRanking(); - - // bulkUpdateRanking은 @Modifying(clearAutomatically = true)이므로 영속성 컨텍스트가 초기화됨 - User high = userRepository.findByUsername("high").orElseThrow(); - User mid = userRepository.findByUsername("mid").orElseThrow(); - User low = userRepository.findByUsername("low").orElseThrow(); - - // RANK() OVER (ORDER BY total_score DESC) - assertThat(high.getRanking()).isEqualTo(1); - assertThat(mid.getRanking()).isEqualTo(2); - assertThat(low.getRanking()).isEqualTo(3); - - // CUME_DIST() OVER (ORDER BY total_score DESC) * 100 - // high: 1/3 * 100 ≈ 33.33, mid: 2/3 * 100 ≈ 66.67, low: 3/3 * 100 = 100.0 - assertThat(high.getPercentile()).isCloseTo(33.33, org.assertj.core.data.Offset.offset(0.1)); - assertThat(mid.getPercentile()).isCloseTo(66.67, org.assertj.core.data.Offset.offset(0.1)); - assertThat(low.getPercentile()).isCloseTo(100.0, org.assertj.core.data.Offset.offset(0.1)); - - // 티어 검증: SQL의 CASE 분기와 Java RankInfo.calculateTier()가 동일한 결과 - // high: score=3000 >= 2000, percentile=33.33 <= 45 → PLATINUM - // mid: score=1500 >= 1500 → GOLD - // low: score=100 < 500 → IRON - assertThat(high.getTier()).isEqualTo(Tier.PLATINUM); - assertThat(mid.getTier()).isEqualTo(Tier.GOLD); - assertThat(low.getTier()).isEqualTo(Tier.IRON); - } - - @Test - @DisplayName("동점자가 있을 때 같은 ranking이 부여된다") - void should_assignSameRanking_when_scoresAreTied() { - createAndSaveUser("user1", "node1", 1L, 1000); - createAndSaveUser("user2", "node2", 2L, 1000); - createAndSaveUser("user3", "node3", 3L, 500); - - userRepository.bulkUpdateRanking(); - - User user1 = userRepository.findByUsername("user1").orElseThrow(); - User user2 = userRepository.findByUsername("user2").orElseThrow(); - User user3 = userRepository.findByUsername("user3").orElseThrow(); - - // RANK()는 동점자에게 같은 순위 부여 - assertThat(user1.getRanking()).isEqualTo(1); - assertThat(user2.getRanking()).isEqualTo(1); - assertThat(user3.getRanking()).isEqualTo(3); // 1등이 2명이므로 3등 - - // 동점자 티어: score=1000 → SILVER - assertThat(user1.getTier()).isEqualTo(Tier.SILVER); - assertThat(user2.getTier()).isEqualTo(Tier.SILVER); - assertThat(user3.getTier()).isEqualTo(Tier.BRONZE); - } - - @Test - @DisplayName("nodeId로 사용자를 조회할 수 있다") - void should_findUser_when_nodeIdExists() { - createAndSaveUser("testuser", "unique-node-id", 1L, 100); - - Optional found = userRepository.findByNodeId("unique-node-id"); - - assertThat(found).isPresent(); - assertThat(found.get().getUsername()).isEqualTo("testuser"); - } - - @Test - @DisplayName("존재하지 않는 nodeId로 조회하면 빈 Optional을 반환한다") - void should_returnEmpty_when_nodeIdDoesNotExist() { - Optional found = userRepository.findByNodeId("non-existent"); - - assertThat(found).isEmpty(); - } - - @Test - @DisplayName("username으로 사용자를 조회할 수 있다") - void should_findUser_when_usernameExists() { - createAndSaveUser("findme", "node-find", 1L, 100); - - Optional found = userRepository.findByUsername("findme"); - - assertThat(found).isPresent(); - assertThat(found.get().getNodeId()).isEqualTo("node-find"); - } - - @Test - @DisplayName("특정 점수보다 높은 사용자 수를 정확히 카운트한다") - void should_countCorrectly_when_countByScoreValueGreaterThan() { - createAndSaveUser("user1", "node1", 1L, 3000); - createAndSaveUser("user2", "node2", 2L, 1500); - createAndSaveUser("user3", "node3", 3L, 100); - - long count = userRepository.countByScoreValueGreaterThan(1000); - - // score > 1000: user1(3000), user2(1500) → 2명 - assertThat(count).isEqualTo(2); - } - - @Test - @DisplayName("점수 내림차순으로 페이징 조회가 동작한다") - void should_returnPagedResults_when_findAllByScoreDesc() { - createAndSaveUser("user1", "node1", 1L, 3000); - createAndSaveUser("user2", "node2", 2L, 1500); - createAndSaveUser("user3", "node3", 3L, 100); - - Page firstPage = userRepository.findAllByOrderByScoreValueDesc(PageRequest.of(0, 2)); - - assertThat(firstPage.getContent()).hasSize(2); - assertThat(firstPage.getContent().get(0).getUsername()).isEqualTo("user1"); - assertThat(firstPage.getContent().get(1).getUsername()).isEqualTo("user2"); - assertThat(firstPage.getTotalElements()).isEqualTo(3); - assertThat(firstPage.getTotalPages()).isEqualTo(2); - } - - @Test - @DisplayName("티어별로 필터링하여 조회할 수 있다") - void should_filterByTier_when_tierProvided() { - createAndSaveUser("user1", "node1", 1L, 3000); - createAndSaveUser("user2", "node2", 2L, 1500); - createAndSaveUser("user3", "node3", 3L, 100); - - // bulkUpdateRanking으로 티어를 실제 계산한 후 필터링 테스트 - userRepository.bulkUpdateRanking(); - - Page goldUsers = userRepository.findAllByRankInfoTierOrderByScoreValueDesc( - Tier.GOLD, PageRequest.of(0, 10)); - - assertThat(goldUsers.getContent()).hasSize(1); - assertThat(goldUsers.getContent().get(0).getUsername()).isEqualTo("user2"); - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/UserTest.java b/src/test/java/com/gitranker/api/domain/user/UserTest.java deleted file mode 100644 index 3b077c8..0000000 --- a/src/test/java/com/gitranker/api/domain/user/UserTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.gitranker.api.domain.user; - -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import com.gitranker.api.domain.user.vo.RankInfo; -import com.gitranker.api.domain.user.vo.Score; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; - -class UserTest { - - private User createDefaultUser() { - return User.builder() - .githubId(12345L) - .nodeId("MDQ6VXNlcjEyMzQ1") - .username("testuser") - .email("test@example.com") - .profileImage("https://avatars.githubusercontent.com/u/12345") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - } - - @Nested - @DisplayName("생성") - class Creation { - - @Test - @DisplayName("새 사용자는 0점, IRON 티어로 초기화된다") - void should_initializeWithZeroScore_and_ironTier() { - User user = createDefaultUser(); - - assertThat(user.getTotalScore()).isZero(); - assertThat(user.getTier()).isEqualTo(Tier.IRON); - assertThat(user.getRanking()).isZero(); - } - - @Test - @DisplayName("role을 지정하지 않으면 USER로 설정된다") - void should_defaultToUserRole_when_roleIsNull() { - User user = User.builder() - .githubId(1L) - .nodeId("node1") - .username("user1") - .build(); - - assertThat(user.getRole()).isEqualTo(Role.USER); - } - - @Test - @DisplayName("새 사용자는 isNewUser가 true를 반환한다") - void should_beNewUser_when_justCreated() { - User user = createDefaultUser(); - - assertThat(user.isNewUser()).isTrue(); - } - } - - @Nested - @DisplayName("쿨다운 (5분)") - class Cooldown { - - @Test - @DisplayName("생성 직후에는 쿨다운 상태이다") - void should_beCooldownActive_when_justCreated() { - User user = createDefaultUser(); - - // 생성 시 lastFullScanAt = now() 이므로 바로 재스캔 불가 - assertThat(user.canTriggerFullScan()).isFalse(); - } - - @Test - @DisplayName("다음 스캔 가능 시간은 마지막 스캔 시간 + 5분이다") - void should_returnCorrectNextAvailableTime() { - User user = createDefaultUser(); - LocalDateTime nextAvailable = user.getNextFullScanAvailableAt(); - - // lastFullScanAt + 5분 - assertThat(nextAvailable).isAfter(LocalDateTime.now().minusSeconds(1)); - assertThat(nextAvailable).isBefore(LocalDateTime.now().plusMinutes(6)); - } - } - - @Nested - @DisplayName("프로필 업데이트") - class ProfileUpdate { - - @Test - @DisplayName("username이 변경되면 true를 반환한다") - void should_returnTrue_when_usernameChanged() { - User user = createDefaultUser(); - - boolean changed = user.updateProfile("newname", user.getProfileImage(), user.getEmail()); - - assertThat(changed).isTrue(); - assertThat(user.getUsername()).isEqualTo("newname"); - } - - @Test - @DisplayName("profileImage가 변경되면 true를 반환한다") - void should_returnTrue_when_profileImageChanged() { - User user = createDefaultUser(); - - boolean changed = user.updateProfile(user.getUsername(), "https://new-image.png", user.getEmail()); - - assertThat(changed).isTrue(); - assertThat(user.getProfileImage()).isEqualTo("https://new-image.png"); - } - - @Test - @DisplayName("아무것도 변경되지 않으면 false를 반환한다") - void should_returnFalse_when_nothingChanged() { - User user = createDefaultUser(); - - boolean changed = user.updateProfile( - user.getUsername(), user.getProfileImage(), user.getEmail()); - - assertThat(changed).isFalse(); - } - - @Test - @DisplayName("null 값은 기존 값을 유지한다") - void should_keepExistingValues_when_nullPassed() { - User user = createDefaultUser(); - String originalUsername = user.getUsername(); - - boolean changed = user.updateProfile(null, null, null); - - assertThat(changed).isFalse(); - assertThat(user.getUsername()).isEqualTo(originalUsername); - } - } - - @Nested - @DisplayName("점수/랭킹 업데이트") - class ScoreUpdate { - - @Test - @DisplayName("활동 통계로 점수와 랭킹을 동시에 업데이트한다") - void should_updateScoreAndRank_when_statisticsProvided() { - User user = createDefaultUser(); - ActivityStatistics stats = ActivityStatistics.of(100, 20, 10, 5, 15); - - user.updateActivityStatistics(stats, 0, 100); - - assertThat(user.getTotalScore()).isEqualTo(305); - assertThat(user.getRanking()).isEqualTo(1); - } - - @Test - @DisplayName("점수 업데이트 후에는 더 이상 신규 사용자가 아니다") - void should_notBeNewUser_after_scoreUpdate() { - User user = createDefaultUser(); - user.updateScore(Score.of(100)); - - assertThat(user.isNewUser()).isFalse(); - } - } - - @Nested - @DisplayName("티어 비교") - class TierComparison { - - @Test - @DisplayName("IRON 사용자는 isAtLeast(IRON)이 true이다") - void should_returnTrue_when_sameOrHigherTier() { - User user = createDefaultUser(); - - assertThat(user.isAtLeast(Tier.IRON)).isTrue(); - } - - @Test - @DisplayName("IRON 사용자는 isAtLeast(BRONZE)가 false이다") - void should_returnFalse_when_lowerTier() { - User user = createDefaultUser(); - - assertThat(user.isAtLeast(Tier.BRONZE)).isFalse(); - } - - @Test - @DisplayName("점수 업데이트 후 티어가 반영된다") - void should_reflectTierAfterRankInfoUpdate() { - User user = createDefaultUser(); - user.updateRankInfo(RankInfo.of(1, 5.0, 2000)); - - assertThat(user.getTier()).isEqualTo(Tier.MASTER); - assertThat(user.isAtLeast(Tier.GOLD)).isTrue(); - } - } - - @Nested - @DisplayName("기본값") - class Defaults { - - @Test - @DisplayName("새 사용자의 getTotalScore는 0을 반환한다") - void should_returnZero_when_newUser() { - User user = createDefaultUser(); - - assertThat(user.getTotalScore()).isZero(); - assertThat(user.getRanking()).isZero(); - assertThat(user.getPercentile()).isEqualTo(100.0); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java deleted file mode 100644 index 7f71d4f..0000000 --- a/src/test/java/com/gitranker/api/domain/user/service/UserPersistenceServiceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.gitranker.api.domain.user.service; - -import com.gitranker.api.domain.log.ActivityLogOrchestrator; -import com.gitranker.api.domain.ranking.RankingRecalculationService; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.BusinessException; -import org.junit.jupiter.api.DisplayName; -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.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UserPersistenceServiceTest { - - @InjectMocks - private UserPersistenceService userPersistenceService; - - @Mock private UserRepository userRepository; - @Mock private ActivityLogOrchestrator activityLogOrchestrator; - @Mock private RankingRecalculationService rankingRecalculationService; - - private User createUser() { - return User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - } - - @Test - @DisplayName("신규 사용자 저장 시 랭킹 정보를 계산하고 활동 로그를 생성한다") - void should_calculateRankingAndCreateLogs_when_savingNewUser() { - User user = createUser(); - ActivityStatistics totalStats = ActivityStatistics.of(50, 10, 5, 3, 8); - ActivityStatistics baselineStats = ActivityStatistics.empty(); - - when(userRepository.countByScoreValueGreaterThan(anyInt())).thenReturn(0L); - when(userRepository.count()).thenReturn(0L); - when(userRepository.save(user)).thenReturn(user); - - User result = userPersistenceService.saveNewUser(user, totalStats, baselineStats); - - assertThat(result.getTotalScore()).isGreaterThan(0); - verify(userRepository).save(user); - verify(activityLogOrchestrator).createLogsForNewUser(user, totalStats, baselineStats); - verify(rankingRecalculationService).recalculateIfNeeded(); - } - - @Test - @DisplayName("통계 업데이트 시 사용자가 존재하지 않으면 예외가 발생한다") - void should_throwUserNotFound_when_userDoesNotExist() { - when(userRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> userPersistenceService.updateUserStatisticsWithLog( - 999L, ActivityStatistics.empty(), ActivityStatistics.empty())) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.USER_NOT_FOUND)); - } - - @Test - @DisplayName("통계 업데이트 시 점수와 랭킹을 갱신하고 fullScan을 기록한다") - void should_updateStatsAndRecordFullScan_when_userExists() { - User user = createUser(); - ActivityStatistics totalStats = ActivityStatistics.of(50, 10, 5, 3, 8); - ActivityStatistics baselineStats = ActivityStatistics.empty(); - - when(userRepository.findById(any())).thenReturn(Optional.of(user)); - when(userRepository.countByScoreValueGreaterThan(anyInt())).thenReturn(0L); - when(userRepository.count()).thenReturn(1L); - - User result = userPersistenceService.updateUserStatisticsWithLog( - user.getId(), totalStats, baselineStats); - - assertThat(result.getTotalScore()).isGreaterThan(0); - verify(activityLogOrchestrator).updateLogsForRefresh(user, totalStats, baselineStats); - verify(rankingRecalculationService).recalculateIfNeeded(); - } - - @Test - @DisplayName("프로필 업데이트 후 저장한다") - void should_saveUser_when_profileUpdated() { - User user = createUser(); - when(userRepository.save(user)).thenReturn(user); - - User result = userPersistenceService.updateProfile(user, "newname", "https://new.img", "new@email.com"); - - assertThat(result.getUsername()).isEqualTo("newname"); - assertThat(result.getEmail()).isEqualTo("new@email.com"); - verify(userRepository).save(user); - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java deleted file mode 100644 index be83662..0000000 --- a/src/test/java/com/gitranker/api/domain/user/service/UserRefreshServiceTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.gitranker.api.domain.user.service; - -import com.gitranker.api.domain.log.ActivityLog; -import com.gitranker.api.domain.log.ActivityLogService; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.dto.RegisterUserResponse; -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.BusinessException; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.infrastructure.github.GitHubActivityService; -import com.gitranker.api.infrastructure.github.GitHubDataMapper; -import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; -import org.junit.jupiter.api.DisplayName; -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.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UserRefreshServiceTest { - - @InjectMocks - private UserRefreshService userRefreshService; - - @Mock private UserRepository userRepository; - @Mock private UserPersistenceService userPersistenceService; - @Mock private ActivityLogService activityLogService; - @Mock private GitHubActivityService gitHubActivityService; - @Mock private GitHubDataMapper gitHubDataMapper; - @Mock private BaselineStatsCalculator baselineStatsCalculator; - @Mock private BusinessMetrics businessMetrics; - - private User createUserWithCooldownExpired() { - User user = User.builder() - .githubId(1L) - .nodeId("node1") - .username("testuser") - .email("test@test.com") - .profileImage("https://img.com/1") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - // 쿨다운을 통과시키기 위해 recordFullScan 시간을 과거로 맞출 수 없으므로 - // canTriggerFullScan이 false를 반환하는 것은 별도 테스트에서 검증 - return user; - } - - @Test - @DisplayName("존재하지 않는 사용자로 새로고침하면 USER_NOT_FOUND 예외가 발생한다") - void should_throwUserNotFound_when_usernameDoesNotExist() { - when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> userRefreshService.refresh("unknown")) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.USER_NOT_FOUND)); - } - - @Test - @DisplayName("쿨다운 중이면 REFRESH_COOL_DOWN_EXCEEDED 예외가 발생한다") - void should_throwCooldownExceeded_when_refreshedRecently() { - // 방금 생성된 User는 lastFullScanAt=now()이므로 쿨다운 상태 - User user = createUserWithCooldownExpired(); - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> userRefreshService.refresh("testuser")) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorType()) - .isEqualTo(ErrorType.REFRESH_COOL_DOWN_EXCEEDED)); - - verify(gitHubActivityService, never()).fetchRawAllActivities(anyString(), any()); - } - - @Test - @DisplayName("새로고침 성공 시 GitHub API를 호출하고 응답을 반환한다") - void should_fetchGitHubDataAndReturnResponse_when_cooldownPassed() { - User user = mock(User.class); - when(user.canTriggerFullScan()).thenReturn(true); - when(user.getGithubCreatedAt()).thenReturn(LocalDateTime.of(2020, 1, 1, 0, 0)); - when(user.getId()).thenReturn(1L); - when(user.getTotalScore()).thenReturn(0); - - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - - GitHubAllActivitiesResponse rawResponse = mock(GitHubAllActivitiesResponse.class); - when(gitHubActivityService.fetchRawAllActivities(eq("testuser"), any())).thenReturn(rawResponse); - - ActivityStatistics totalStats = ActivityStatistics.of(50, 10, 5, 3, 8); - ActivityStatistics baselineStats = ActivityStatistics.of(10, 2, 1, 0, 2); - when(gitHubDataMapper.toActivityStatistics(rawResponse)).thenReturn(totalStats); - when(baselineStatsCalculator.calculate(user, rawResponse)).thenReturn(baselineStats); - - User updatedUser = mock(User.class); - when(updatedUser.getUsername()).thenReturn("testuser"); - when(updatedUser.getTotalScore()).thenReturn(100); - when(updatedUser.getTier()).thenReturn(com.gitranker.api.domain.user.Tier.IRON); - when(updatedUser.getRanking()).thenReturn(1); - when(updatedUser.getPercentile()).thenReturn(50.0); - when(updatedUser.getRole()).thenReturn(Role.USER); - - when(userPersistenceService.updateUserStatisticsWithLog(eq(1L), eq(totalStats), eq(baselineStats))) - .thenReturn(updatedUser); - - ActivityLog activityLog = ActivityLog.empty(updatedUser, LocalDate.now()); - when(activityLogService.getLatestLog(updatedUser)).thenReturn(activityLog); - - RegisterUserResponse response = userRefreshService.refresh("testuser"); - - assertThat(response).isNotNull(); - verify(gitHubActivityService).fetchRawAllActivities(eq("testuser"), any()); - verify(userPersistenceService).updateUserStatisticsWithLog(eq(1L), eq(totalStats), eq(baselineStats)); - verify(businessMetrics).incrementRefreshes(); - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java b/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java deleted file mode 100644 index f30c431..0000000 --- a/src/test/java/com/gitranker/api/domain/user/service/UserRegistrationServiceTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.gitranker.api.domain.user.service; - -import com.gitranker.api.domain.log.ActivityLog; -import com.gitranker.api.domain.log.ActivityLogService; -import com.gitranker.api.domain.user.Role; -import com.gitranker.api.domain.user.User; -import com.gitranker.api.domain.user.UserRepository; -import com.gitranker.api.domain.user.dto.RegisterUserResponse; -import com.gitranker.api.domain.user.vo.ActivityStatistics; -import com.gitranker.api.global.auth.OAuthAttributes; -import com.gitranker.api.global.metrics.BusinessMetrics; -import com.gitranker.api.infrastructure.github.GitHubActivityService; -import com.gitranker.api.infrastructure.github.GitHubDataMapper; -import com.gitranker.api.infrastructure.github.dto.GitHubAllActivitiesResponse; -import org.junit.jupiter.api.DisplayName; -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.LocalDate; -import java.time.LocalDateTime; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class UserRegistrationServiceTest { - - @InjectMocks - private UserRegistrationService userRegistrationService; - - @Mock private UserRepository userRepository; - @Mock private UserPersistenceService userPersistenceService; - @Mock private ActivityLogService activityLogService; - @Mock private GitHubActivityService gitHubActivityService; - @Mock private GitHubDataMapper gitHubDataMapper; - @Mock private BaselineStatsCalculator baselineStatsCalculator; - @Mock private BusinessMetrics businessMetrics; - - private OAuthAttributes createOAuthAttributes(String username) { - Map attrs = Map.of( - "id", 12345, - "node_id", "MDQ6VXNlcjEyMzQ1", - "login", username, - "email", "test@test.com", - "avatar_url", "https://img.com/1", - "created_at", "2020-01-01T00:00:00Z" - ); - return OAuthAttributes.of("id", attrs); - } - - @Test - @DisplayName("신규 사용자이면 GitHub 데이터를 수집하고 저장한다") - void should_fetchGitHubDataAndSave_when_newUser() { - OAuthAttributes attributes = createOAuthAttributes("newuser"); - when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")).thenReturn(Optional.empty()); - - GitHubAllActivitiesResponse rawResponse = mock(GitHubAllActivitiesResponse.class); - when(gitHubActivityService.fetchRawAllActivities(eq("newuser"), any())).thenReturn(rawResponse); - - ActivityStatistics totalStats = ActivityStatistics.of(10, 2, 1, 0, 3); - ActivityStatistics baselineStats = ActivityStatistics.empty(); - when(gitHubDataMapper.toActivityStatistics(rawResponse)).thenReturn(totalStats); - when(baselineStatsCalculator.calculate(any(User.class), eq(rawResponse))).thenReturn(baselineStats); - - User savedUser = User.builder() - .githubId(12345L) - .nodeId("MDQ6VXNlcjEyMzQ1") - .username("newuser") - .email("test@test.com") - .profileImage("https://img.com/1") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - when(userPersistenceService.saveNewUser(any(User.class), eq(totalStats), eq(baselineStats))) - .thenReturn(savedUser); - - ActivityLog activityLog = ActivityLog.empty(savedUser, LocalDate.now()); - when(activityLogService.findLatestLog(savedUser)).thenReturn(Optional.of(activityLog)); - - RegisterUserResponse response = userRegistrationService.register(attributes); - - assertThat(response).isNotNull(); - assertThat(response.isNewUser()).isTrue(); - verify(gitHubActivityService).fetchRawAllActivities(eq("newuser"), any()); - verify(userPersistenceService).saveNewUser(any(User.class), eq(totalStats), eq(baselineStats)); - verify(businessMetrics).incrementRegistrations(); - } - - @Test - @DisplayName("기존 사용자이면 GitHub 데이터를 수집하지 않고 기존 정보를 반환한다") - void should_returnExisting_when_userAlreadyExists() { - OAuthAttributes attributes = createOAuthAttributes("existinguser"); - - User existingUser = User.builder() - .githubId(12345L) - .nodeId("MDQ6VXNlcjEyMzQ1") - .username("existinguser") - .email("test@test.com") - .profileImage("https://img.com/1") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")).thenReturn(Optional.of(existingUser)); - - ActivityLog activityLog = ActivityLog.empty(existingUser, LocalDate.now()); - when(activityLogService.findLatestLog(existingUser)).thenReturn(Optional.of(activityLog)); - - RegisterUserResponse response = userRegistrationService.register(attributes); - - assertThat(response).isNotNull(); - assertThat(response.isNewUser()).isFalse(); - verify(gitHubActivityService, never()).fetchRawAllActivities(anyString(), any()); - verify(businessMetrics, never()).incrementRegistrations(); - } - - @Test - @DisplayName("기존 사용자의 프로필이 변경되었으면 업데이트한다") - void should_updateProfile_when_existingUserInfoChanged() { - Map attrs = Map.of( - "id", 12345, - "node_id", "MDQ6VXNlcjEyMzQ1", - "login", "newname", - "email", "test@test.com", - "avatar_url", "https://img.com/new", - "created_at", "2020-01-01T00:00:00Z" - ); - OAuthAttributes attributes = OAuthAttributes.of("id", attrs); - - User existingUser = User.builder() - .githubId(12345L) - .nodeId("MDQ6VXNlcjEyMzQ1") - .username("oldname") - .email("test@test.com") - .profileImage("https://img.com/old") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - when(userRepository.findByNodeId("MDQ6VXNlcjEyMzQ1")).thenReturn(Optional.of(existingUser)); - - User updatedUser = User.builder() - .githubId(12345L) - .nodeId("MDQ6VXNlcjEyMzQ1") - .username("newname") - .email("test@test.com") - .profileImage("https://img.com/new") - .githubCreatedAt(LocalDateTime.of(2020, 1, 1, 0, 0)) - .role(Role.USER) - .build(); - when(userPersistenceService.updateProfile(existingUser, "newname", "https://img.com/new", "test@test.com")) - .thenReturn(updatedUser); - - ActivityLog activityLog = ActivityLog.empty(updatedUser, LocalDate.now()); - when(activityLogService.findLatestLog(updatedUser)).thenReturn(Optional.of(activityLog)); - - RegisterUserResponse response = userRegistrationService.register(attributes); - - assertThat(response.username()).isEqualTo("newname"); - verify(userPersistenceService).updateProfile(existingUser, "newname", "https://img.com/new", "test@test.com"); - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java b/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java deleted file mode 100644 index bc51753..0000000 --- a/src/test/java/com/gitranker/api/domain/user/vo/ActivityStatisticsTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.gitranker.api.domain.user.vo; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ActivityStatisticsTest { - - @Nested - @DisplayName("calculateScore") - class CalculateScore { - - @Test - @DisplayName("통계에서 점수를 정확하게 계산한다") - void should_calculateCorrectScore() { - ActivityStatistics stats = ActivityStatistics.of(100, 20, 10, 5, 15); - Score score = stats.calculateScore(); - - // commits=100*1 + issues=20*2 + reviews=15*5 + prOpened=10*5 + prMerged=5*8 = 100+40+75+50+40 = 305 - assertThat(score.getValue()).isEqualTo(305); - } - } - - @Nested - @DisplayName("empty") - class Empty { - - @Test - @DisplayName("빈 통계는 모든 값이 0이다") - void should_returnAllZeros() { - ActivityStatistics empty = ActivityStatistics.empty(); - - assertThat(empty.getCommitCount()).isZero(); - assertThat(empty.getIssueCount()).isZero(); - assertThat(empty.getPrOpenedCount()).isZero(); - assertThat(empty.getPrMergedCount()).isZero(); - assertThat(empty.getReviewCount()).isZero(); - } - - @Test - @DisplayName("빈 통계는 활동이 없다고 판별한다") - void should_returnFalse_for_hasActivity() { - assertThat(ActivityStatistics.empty().hasActivity()).isFalse(); - } - } - - @Nested - @DisplayName("calculateDiff") - class CalculateDiff { - - @Test - @DisplayName("이전 통계와의 차이를 정확하게 계산한다") - void should_calculateDiffCorrectly() { - ActivityStatistics current = ActivityStatistics.of(50, 10, 8, 3, 12); - ActivityStatistics previous = ActivityStatistics.of(40, 8, 5, 2, 10); - - ActivityStatistics diff = current.calculateDiff(previous); - - assertThat(diff.getCommitCount()).isEqualTo(10); - assertThat(diff.getIssueCount()).isEqualTo(2); - assertThat(diff.getPrOpenedCount()).isEqualTo(3); - assertThat(diff.getPrMergedCount()).isEqualTo(1); - assertThat(diff.getReviewCount()).isEqualTo(2); - } - - @Test - @DisplayName("동일한 통계의 diff는 모두 0이다") - void should_returnZeroDiff_when_sameStatistics() { - ActivityStatistics stats = ActivityStatistics.of(10, 5, 3, 2, 4); - - ActivityStatistics diff = stats.calculateDiff(stats); - - assertThat(diff).isEqualTo(ActivityStatistics.empty()); - } - } - - @Nested - @DisplayName("merge") - class Merge { - - @Test - @DisplayName("두 통계를 합산한다") - void should_mergeCorrectly() { - ActivityStatistics a = ActivityStatistics.of(10, 5, 3, 2, 4); - ActivityStatistics b = ActivityStatistics.of(20, 3, 1, 1, 6); - - ActivityStatistics merged = a.merge(b); - - assertThat(merged.getCommitCount()).isEqualTo(30); - assertThat(merged.getIssueCount()).isEqualTo(8); - assertThat(merged.getPrOpenedCount()).isEqualTo(4); - assertThat(merged.getPrMergedCount()).isEqualTo(3); - assertThat(merged.getReviewCount()).isEqualTo(10); - } - - @Test - @DisplayName("빈 통계와 merge하면 원본과 동일하다") - void should_returnSame_when_mergedWithEmpty() { - ActivityStatistics stats = ActivityStatistics.of(10, 5, 3, 2, 4); - - ActivityStatistics merged = stats.merge(ActivityStatistics.empty()); - - assertThat(merged).isEqualTo(stats); - } - } - - @Nested - @DisplayName("hasActivity") - class HasActivity { - - @Test - @DisplayName("하나라도 활동이 있으면 true를 반환한다") - void should_returnTrue_when_anyActivityExists() { - assertThat(ActivityStatistics.of(1, 0, 0, 0, 0).hasActivity()).isTrue(); - assertThat(ActivityStatistics.of(0, 1, 0, 0, 0).hasActivity()).isTrue(); - assertThat(ActivityStatistics.of(0, 0, 1, 0, 0).hasActivity()).isTrue(); - assertThat(ActivityStatistics.of(0, 0, 0, 1, 0).hasActivity()).isTrue(); - assertThat(ActivityStatistics.of(0, 0, 0, 0, 1).hasActivity()).isTrue(); - } - } - - @Nested - @DisplayName("totalActivityCount") - class TotalActivityCount { - - @Test - @DisplayName("전체 활동 수를 정확하게 합산한다") - void should_sumAllActivities() { - ActivityStatistics stats = ActivityStatistics.of(10, 5, 3, 2, 4); - - assertThat(stats.totalActivityCount()).isEqualTo(24); - } - } - - @Nested - @DisplayName("동등성") - class Equality { - - @Test - @DisplayName("같은 값의 통계는 동등하다") - void should_beEqual_when_sameValues() { - ActivityStatistics a = ActivityStatistics.of(10, 5, 3, 2, 4); - ActivityStatistics b = ActivityStatistics.of(10, 5, 3, 2, 4); - - assertThat(a).isEqualTo(b); - } - - @Test - @DisplayName("동등한 객체는 같은 hashCode를 가진다") - void should_haveSameHashCode_when_equal() { - ActivityStatistics a = ActivityStatistics.of(10, 5, 3, 2, 4); - ActivityStatistics b = ActivityStatistics.of(10, 5, 3, 2, 4); - - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java b/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java deleted file mode 100644 index dd2382f..0000000 --- a/src/test/java/com/gitranker/api/domain/user/vo/RankInfoTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.gitranker.api.domain.user.vo; - -import com.gitranker.api.domain.user.Tier; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class RankInfoTest { - - @Nested - @DisplayName("절대 티어 (점수 기반)") - class AbsoluteTier { - - @ParameterizedTest - @DisplayName("점수 구간에 따라 올바른 절대 티어를 반환한다") - @CsvSource({ - "0, IRON", - "499, IRON", - "500, BRONZE", - "999, BRONZE", - "1000, SILVER", - "1499, SILVER", - "1500, GOLD", - "1999, GOLD" - }) - void should_returnCorrectAbsoluteTier(int score, Tier expected) { - // percentile 50% -> 상대 티어 조건 미충족 - RankInfo rankInfo = RankInfo.of(1, 50.0, score); - - assertThat(rankInfo.getTier()).isEqualTo(expected); - } - } - - @Nested - @DisplayName("상대 티어 (백분위 기반, 2000점 이상)") - class RelativeTier { - - @ParameterizedTest - @DisplayName("2000점 이상에서 백분위에 따라 상대 티어를 반환한다") - @CsvSource({ - "0.5, CHALLENGER", - "1.0, CHALLENGER", - "1.1, MASTER", - "5.0, MASTER", - "5.1, DIAMOND", - "12.0, DIAMOND", - "12.1, EMERALD", - "25.0, EMERALD", - "25.1, PLATINUM", - "45.0, PLATINUM" - }) - void should_returnCorrectRelativeTier_when_scoreAbove2000(double percentile, Tier expected) { - RankInfo rankInfo = RankInfo.of(1, percentile, 2000); - - assertThat(rankInfo.getTier()).isEqualTo(expected); - } - - @Test - @DisplayName("2000점 이상이어도 백분위가 45%를 넘으면 GOLD로 떨어진다") - void should_fallbackToGold_when_percentileAbove45() { - RankInfo rankInfo = RankInfo.of(1, 45.1, 2000); - - assertThat(rankInfo.getTier()).isEqualTo(Tier.GOLD); - } - - @Test - @DisplayName("1999점이면 top 1%여도 상대 티어가 아닌 GOLD를 반환한다") - void should_returnGold_when_scoreBelowThresholdEvenIfTopPercent() { - RankInfo rankInfo = RankInfo.of(1, 0.5, 1999); - - assertThat(rankInfo.getTier()).isEqualTo(Tier.GOLD); - } - } - - @Nested - @DisplayName("calculate") - class Calculate { - - @Test - @DisplayName("전체 사용자 수가 0이면 초기값을 반환한다") - void should_returnInitial_when_noUsers() { - RankInfo rankInfo = RankInfo.calculate(0, 0, 500); - - assertThat(rankInfo.getRanking()).isZero(); - assertThat(rankInfo.getPercentile()).isEqualTo(100.0); - assertThat(rankInfo.getTier()).isEqualTo(Tier.IRON); - } - - @Test - @DisplayName("higherScoreCount로부터 정확한 랭킹을 계산한다") - void should_calculateRanking_from_higherScoreCount() { - // 나보다 높은 사람 9명, 전체 100명 -> 랭킹 10위, 백분위 10% - RankInfo rankInfo = RankInfo.calculate(9, 100, 2000); - - assertThat(rankInfo.getRanking()).isEqualTo(10); - assertThat(rankInfo.getPercentile()).isEqualTo(10.0); - } - - @Test - @DisplayName("1등은 백분위 계산이 정확하다") - void should_calculateFirstPlace_correctly() { - // 나보다 높은 사람 0명, 전체 100명 -> 1위, 1% - RankInfo rankInfo = RankInfo.calculate(0, 100, 3000); - - assertThat(rankInfo.getRanking()).isEqualTo(1); - assertThat(rankInfo.getPercentile()).isEqualTo(1.0); - assertThat(rankInfo.getTier()).isEqualTo(Tier.CHALLENGER); - } - - @Test - @DisplayName("유일한 사용자는 1등이지만 백분위 100%라 상대 티어를 받지 못한다") - void should_beFirstRank_but_fallbackToAbsoluteTier_when_onlyOneUser() { - RankInfo rankInfo = RankInfo.calculate(0, 1, 2500); - - assertThat(rankInfo.getRanking()).isEqualTo(1); - assertThat(rankInfo.getPercentile()).isEqualTo(100.0); - // 2500점이지만 percentile 100% > 45%이므로 상대 티어(PLATINUM~CHALLENGER)가 아닌 GOLD - assertThat(rankInfo.getTier()).isEqualTo(Tier.GOLD); - } - } - - @Nested - @DisplayName("initial") - class Initial { - - @Test - @DisplayName("초기 RankInfo는 0위, 100%, IRON이다") - void should_returnDefaultValues() { - RankInfo initial = RankInfo.initial(); - - assertThat(initial.getRanking()).isZero(); - assertThat(initial.getPercentile()).isEqualTo(100.0); - assertThat(initial.getTier()).isEqualTo(Tier.IRON); - } - } - - @Nested - @DisplayName("티어 승급 판별") - class TierPromotion { - - @Test - @DisplayName("티어가 올라갔으면 승급으로 판별한다") - void should_detectPromotion_when_tierImproved() { - RankInfo previous = RankInfo.of(10, 50.0, 400); // IRON - RankInfo current = RankInfo.of(5, 30.0, 600); // BRONZE - - assertThat(current.isTierPromoted(previous)).isTrue(); - } - - @Test - @DisplayName("티어가 같으면 승급이 아니다") - void should_notDetectPromotion_when_sameTier() { - RankInfo previous = RankInfo.of(10, 50.0, 600); // BRONZE - RankInfo current = RankInfo.of(5, 30.0, 700); // BRONZE - - assertThat(current.isTierPromoted(previous)).isFalse(); - } - } - - @Nested - @DisplayName("유효성 검증") - class Validation { - - @Test - @DisplayName("랭킹이 음수이면 예외가 발생한다") - void should_throwException_when_negativeRanking() { - assertThatThrownBy(() -> RankInfo.of(-1, 50.0, 500)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @DisplayName("백분위가 0 미만이면 예외가 발생한다") - void should_throwException_when_percentileBelowZero() { - assertThatThrownBy(() -> RankInfo.of(1, -0.1, 500)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @DisplayName("백분위가 100 초과이면 예외가 발생한다") - void should_throwException_when_percentileAbove100() { - assertThatThrownBy(() -> RankInfo.of(1, 100.1, 500)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @Nested - @DisplayName("isTopPercent") - class IsTopPercent { - - @Test - @DisplayName("백분위가 임계값 이하이면 true를 반환한다") - void should_returnTrue_when_withinThreshold() { - RankInfo rankInfo = RankInfo.of(1, 5.0, 2000); - - assertThat(rankInfo.isTopPercent(5.0)).isTrue(); - assertThat(rankInfo.isTopPercent(10.0)).isTrue(); - } - - @Test - @DisplayName("백분위가 임계값 초과이면 false를 반환한다") - void should_returnFalse_when_exceedsThreshold() { - RankInfo rankInfo = RankInfo.of(1, 5.1, 2000); - - assertThat(rankInfo.isTopPercent(5.0)).isFalse(); - } - } -} diff --git a/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java b/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java deleted file mode 100644 index 67602e5..0000000 --- a/src/test/java/com/gitranker/api/domain/user/vo/ScoreTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.gitranker.api.domain.user.vo; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ScoreTest { - - @Nested - @DisplayName("calculate") - class Calculate { - - @Test - @DisplayName("가중치 공식에 따라 정확한 점수를 계산한다") - void should_calculateCorrectScore_when_allActivitiesProvided() { - // commits=10*1 + issues=5*2 + reviews=3*5 + prOpened=2*5 + prMerged=1*8 = 10+10+15+10+8 = 53 - Score score = Score.calculate(10, 5, 3, 2, 1); - - assertThat(score.getValue()).isEqualTo(53); - } - - @Test - @DisplayName("모든 활동이 0이면 점수도 0이다") - void should_returnZero_when_allActivitiesAreZero() { - Score score = Score.calculate(0, 0, 0, 0, 0); - - assertThat(score.getValue()).isZero(); - } - - @Test - @DisplayName("PR Merged 가중치(8)가 가장 높다") - void should_weightPrMergedHighest() { - Score onlyMerged = Score.calculate(0, 0, 0, 0, 1); // 8 - Score onlyCommit = Score.calculate(1, 0, 0, 0, 0); // 1 - Score onlyIssue = Score.calculate(0, 1, 0, 0, 0); // 2 - Score onlyReview = Score.calculate(0, 0, 1, 0, 0); // 5 - Score onlyPrOpened = Score.calculate(0, 0, 0, 1, 0); // 5 - - assertThat(onlyMerged.getValue()).isGreaterThan(onlyPrOpened.getValue()); - assertThat(onlyMerged.getValue()).isGreaterThan(onlyReview.getValue()); - assertThat(onlyMerged.getValue()).isGreaterThan(onlyIssue.getValue()); - assertThat(onlyMerged.getValue()).isGreaterThan(onlyCommit.getValue()); - } - - @ParameterizedTest - @DisplayName("각 활동별 가중치가 올바르게 적용된다") - @CsvSource({ - "1, 0, 0, 0, 0, 1", // commit * 1 - "0, 1, 0, 0, 0, 2", // issue * 2 - "0, 0, 1, 0, 0, 5", // review * 5 - "0, 0, 0, 1, 0, 5", // prOpened * 5 - "0, 0, 0, 0, 1, 8" // prMerged * 8 - }) - void should_applyCorrectWeight_for_eachActivity( - int commits, int issues, int reviews, int prOpened, int prMerged, int expected) { - Score score = Score.calculate(commits, issues, reviews, prOpened, prMerged); - - assertThat(score.getValue()).isEqualTo(expected); - } - } - - @Nested - @DisplayName("of / zero") - class FactoryMethods { - - @Test - @DisplayName("of로 특정 점수의 Score를 생성한다") - void should_createScore_when_validValue() { - Score score = Score.of(1500); - - assertThat(score.getValue()).isEqualTo(1500); - } - - @Test - @DisplayName("zero는 0점 Score를 반환한다") - void should_returnZeroScore_when_callingZero() { - Score score = Score.zero(); - - assertThat(score.getValue()).isZero(); - } - - @Test - @DisplayName("음수 값으로 생성하면 예외가 발생한다") - void should_throwException_when_negativeValue() { - assertThatThrownBy(() -> Score.of(-1)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @Nested - @DisplayName("비교 연산") - class Comparison { - - @Test - @DisplayName("높은 점수가 낮은 점수보다 크다고 판별한다") - void should_returnTrue_when_higherScore() { - Score higher = Score.of(100); - Score lower = Score.of(50); - - assertThat(higher.isHigherThan(lower)).isTrue(); - assertThat(lower.isHigherThan(higher)).isFalse(); - } - - @Test - @DisplayName("같은 점수는 크지 않다고 판별한다") - void should_returnFalse_when_equalScore() { - Score a = Score.of(100); - Score b = Score.of(100); - - assertThat(a.isHigherThan(b)).isFalse(); - } - - @Test - @DisplayName("두 점수의 차이를 정확히 계산한다") - void should_calculateDifference() { - Score a = Score.of(100); - Score b = Score.of(30); - - assertThat(a.differenceFrom(b)).isEqualTo(70); - assertThat(b.differenceFrom(a)).isEqualTo(-70); - } - } - - @Nested - @DisplayName("동등성") - class Equality { - - @Test - @DisplayName("같은 값의 Score는 동등하다") - void should_beEqual_when_sameValue() { - Score a = Score.of(500); - Score b = Score.of(500); - - assertThat(a).isEqualTo(b); - } - - @Test - @DisplayName("다른 값의 Score는 동등하지 않다") - void should_notBeEqual_when_differentValue() { - Score a = Score.of(500); - Score b = Score.of(501); - - assertThat(a).isNotEqualTo(b); - } - - @Test - @DisplayName("동등한 객체는 같은 hashCode를 가진다") - void should_haveSameHashCode_when_equal() { - Score a = Score.of(500); - Score b = Score.of(500); - - assertThat(a.hashCode()).isEqualTo(b.hashCode()); - } - } -} diff --git a/src/test/java/com/gitranker/api/global/auth/jwt/JwtProviderTest.java b/src/test/java/com/gitranker/api/global/auth/jwt/JwtProviderTest.java deleted file mode 100644 index c715902..0000000 --- a/src/test/java/com/gitranker/api/global/auth/jwt/JwtProviderTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.gitranker.api.global.auth.jwt; - -import com.gitranker.api.domain.user.Role; -import io.jsonwebtoken.Jwts; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; - -import javax.crypto.SecretKey; -import java.time.LocalDateTime; -import java.util.Base64; - -import static org.assertj.core.api.Assertions.assertThat; - -class JwtProviderTest { - - private JwtProvider jwtProvider; - - private static final String TEST_SECRET = Base64.getEncoder().encodeToString( - "test-secret-key-for-jwt-must-be-at-least-64-bytes-long-for-hs512-algorithm-padding".getBytes() - ); - private static final long ACCESS_TOKEN_EXPIRATION = 3600000L; // 1시간 - private static final long REFRESH_TOKEN_EXPIRATION = 604800000L; // 7일 - - @BeforeEach - void setUp() { - jwtProvider = new JwtProvider(); - ReflectionTestUtils.setField(jwtProvider, "secret", TEST_SECRET); - ReflectionTestUtils.setField(jwtProvider, "accessTokenExpirationMs", ACCESS_TOKEN_EXPIRATION); - ReflectionTestUtils.setField(jwtProvider, "refreshTokenExpirationMs", REFRESH_TOKEN_EXPIRATION); - jwtProvider.init(); - } - - @Nested - @DisplayName("createAccessToken") - class CreateAccessToken { - - @Test - @DisplayName("유효한 JWT를 생성한다") - void should_createValidJwt() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(token).isNotNull(); - assertThat(jwtProvider.validateToken(token)).isTrue(); - } - - @Test - @DisplayName("토큰에 올바른 username이 포함된다") - void should_containCorrectUsername() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.getUsername(token)).isEqualTo("testuser"); - } - - @Test - @DisplayName("생성된 토큰은 access 타입이다") - void should_beAccessTokenType() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.isAccessToken(token)).isTrue(); - } - } - - @Nested - @DisplayName("validateToken") - class ValidateToken { - - @Test - @DisplayName("유효한 토큰이면 true를 반환한다") - void should_returnTrue_when_validToken() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.validateToken(token)).isTrue(); - } - - @Test - @DisplayName("만료된 토큰이면 false를 반환한다") - void should_returnFalse_when_expiredToken() { - ReflectionTestUtils.setField(jwtProvider, "accessTokenExpirationMs", -1000L); - String expiredToken = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.validateToken(expiredToken)).isFalse(); - - // 원복 - ReflectionTestUtils.setField(jwtProvider, "accessTokenExpirationMs", ACCESS_TOKEN_EXPIRATION); - } - - @Test - @DisplayName("변조된 토큰이면 false를 반환한다") - void should_returnFalse_when_tamperedToken() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - String tampered = token.substring(0, token.length() - 5) + "xxxxx"; - - assertThat(jwtProvider.validateToken(tampered)).isFalse(); - } - - @Test - @DisplayName("다른 키로 서명된 토큰이면 false를 반환한다") - void should_returnFalse_when_differentSecretKey() { - // 별도 키로 서명한 토큰 생성 - String otherSecret = Base64.getEncoder().encodeToString( - "another-secret-key-for-test-must-be-at-least-64-bytes-long-for-hs512-algorithm-pad".getBytes() - ); - JwtProvider otherProvider = new JwtProvider(); - ReflectionTestUtils.setField(otherProvider, "secret", otherSecret); - ReflectionTestUtils.setField(otherProvider, "accessTokenExpirationMs", ACCESS_TOKEN_EXPIRATION); - ReflectionTestUtils.setField(otherProvider, "refreshTokenExpirationMs", REFRESH_TOKEN_EXPIRATION); - otherProvider.init(); - - String tokenFromOtherKey = otherProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.validateToken(tokenFromOtherKey)).isFalse(); - } - - @Test - @DisplayName("빈 문자열이면 false를 반환한다") - void should_returnFalse_when_emptyToken() { - assertThat(jwtProvider.validateToken("")).isFalse(); - } - - @Test - @DisplayName("형식이 잘못된 문자열이면 false를 반환한다") - void should_returnFalse_when_malformedToken() { - assertThat(jwtProvider.validateToken("not.a.jwt")).isFalse(); - } - } - - @Nested - @DisplayName("isAccessToken") - class IsAccessToken { - - @Test - @DisplayName("access 토큰이면 true를 반환한다") - void should_returnTrue_when_accessToken() { - String token = jwtProvider.createAccessToken("testuser", Role.USER); - - assertThat(jwtProvider.isAccessToken(token)).isTrue(); - } - - @Test - @DisplayName("유효하지 않은 토큰이면 false를 반환한다") - void should_returnFalse_when_invalidToken() { - assertThat(jwtProvider.isAccessToken("invalid")).isFalse(); - } - } - - @Nested - @DisplayName("createRefreshToken") - class CreateRefreshToken { - - @Test - @DisplayName("null이 아닌 토큰을 생성한다") - void should_createNonNullToken() { - String token = jwtProvider.createRefreshToken(); - - assertThat(token).isNotBlank(); - } - - @Test - @DisplayName("매 호출마다 다른 토큰을 생성한다") - void should_createUniqueTokens() { - String token1 = jwtProvider.createRefreshToken(); - String token2 = jwtProvider.createRefreshToken(); - - assertThat(token1).isNotEqualTo(token2); - } - } - - @Nested - @DisplayName("calculateRefreshTokenExpiry") - class CalculateRefreshTokenExpiry { - - @Test - @DisplayName("현재 시간 이후의 만료 시간을 반환한다") - void should_returnFutureDateTime() { - LocalDateTime before = LocalDateTime.now(); - LocalDateTime expiry = jwtProvider.calculateRefreshTokenExpiry(); - - assertThat(expiry).isAfter(before); - } - } -} diff --git a/src/test/java/com/gitranker/api/global/logging/LogContextTest.java b/src/test/java/com/gitranker/api/global/logging/LogContextTest.java deleted file mode 100644 index 1f1ff32..0000000 --- a/src/test/java/com/gitranker/api/global/logging/LogContextTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.gitranker.api.global.logging; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -class LogContextTest { - - @AfterEach - void tearDown() { - LogContext.clear(); - } - - @Test - @DisplayName("요청 컨텍스트가 있으면 필수 구조화 필드를 포함해 로그를 남긴다") - void should_includeRequiredStructuredFields_when_requestContextExists() { - ListAppender appender = attachAppender(); - try { - LogContext.initRequest( - "trace-1234", - "127.0.0.1", - "JUnit", - "GET", - "/api/v1/users/tester" - ); - - LogContext.event(Event.PROFILE_VIEWED) - .with("username", "tester") - .info(); - - assertThat(appender.list).hasSize(1); - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - - assertThat(mdc) - .containsEntry("trace_id", "trace-1234") - .containsEntry("event", "PROFILE_VIEWED") - .containsEntry("log_category", "USER") - .containsEntry("phase", "user") - .containsEntry("outcome", "success") - .containsEntry("username", "te****") - .containsEntry("request_method", "GET") - .containsEntry("request_uri", "/api/v1/users/tester"); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("trace_id가 없으면 자동 생성하고 error 로그는 failure outcome을 기록한다") - void should_generateTraceIdAndFailureOutcome_when_errorWithoutTraceId() { - ListAppender appender = attachAppender(); - try { - LogContext.event(Event.ERROR_HANDLED) - .with("error_code", "DEFAULT_ERROR") - .error(); - - assertThat(appender.list).hasSize(1); - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - - assertThat(mdc) - .containsEntry("event", "ERROR_HANDLED") - .containsEntry("log_category", "ERROR") - .containsEntry("phase", "error") - .containsEntry("outcome", "failure"); - assertThat(mdc.get("trace_id")).isNotBlank(); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("phase와 outcome을 명시하면 기본값을 덮어쓰지 않는다") - void should_keepExplicitPhaseAndOutcome_when_overridden() { - ListAppender appender = attachAppender(); - try { - LogContext.setTraceId("trace-batch"); - - LogContext.event(Event.BATCH_ITEM_FAILED) - .with("phase", "PROCESS") - .with("outcome", "failure") - .warn(); - - assertThat(appender.list).hasSize(1); - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - - assertThat(mdc) - .containsEntry("phase", "PROCESS") - .containsEntry("outcome", "failure"); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("인증 컨텍스트 username은 마스킹되어 기록한다") - void should_maskUsername_when_setAuthContextIsUsed() { - ListAppender appender = attachAppender(); - try { - LogContext.initRequest("trace-auth", "127.0.0.1", "JUnit", "GET", "/api/v1/auth/me"); - LogContext.setAuthContext("octocat"); - - LogContext.event(Event.USER_LOGIN).info(); - - assertThat(appender.list).hasSize(1); - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - assertThat(mdc) - .containsEntry("trace_id", "trace-auth") - .containsEntry("event", "USER_LOGIN") - .containsEntry("log_category", "USER") - .containsEntry("username", "oc*****"); - } finally { - detachAppender(appender); - } - } - - private ListAppender attachAppender() { - Logger logger = (Logger) LoggerFactory.getLogger(LogContext.class); - ListAppender appender = new ListAppender<>(); - appender.start(); - logger.addAppender(appender); - return appender; - } - - private void detachAppender(ListAppender appender) { - Logger logger = (Logger) LoggerFactory.getLogger(LogContext.class); - logger.detachAppender(appender); - appender.stop(); - MDC.clear(); - } -} diff --git a/src/test/java/com/gitranker/api/global/logging/LogSanitizerTest.java b/src/test/java/com/gitranker/api/global/logging/LogSanitizerTest.java deleted file mode 100644 index 568baa4..0000000 --- a/src/test/java/com/gitranker/api/global/logging/LogSanitizerTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.gitranker.api.global.logging; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class LogSanitizerTest { - - @Test - @DisplayName("username 필드는 구조화 로그에서 자동 마스킹한다") - void should_maskUsernameField_when_structuredFieldIsUsername() { - Object sanitized = LogSanitizer.sanitizeStructuredField("username", "tester"); - - assertThat(sanitized).isEqualTo("te****"); - } - - @Test - @DisplayName("target_username 필드도 자동 마스킹한다") - void should_maskTargetUsernameField_when_structuredFieldIsTargetUsername() { - Object sanitized = LogSanitizer.sanitizeStructuredField("target_username", "octocat"); - - assertThat(sanitized).isEqualTo("oc*****"); - } - - @Test - @DisplayName("username 외 필드는 원본 값을 유지한다") - void should_keepOriginalValue_when_fieldIsNotUsername() { - Object sanitized = LogSanitizer.sanitizeStructuredField("node_id", "MDQ6VXNlcjE="); - - assertThat(sanitized).isEqualTo("MDQ6VXNlcjE="); - } - - @Test - @DisplayName("username 해시는 고정 길이 식별자로 반환한다") - void should_hashUsername_withDeterministicLength() { - String hash = LogSanitizer.hashUsername("tester"); - - assertThat(hash).hasSize(12); - assertThat(hash).matches("[0-9a-f]{12}"); - assertThat(hash).isEqualTo(LogSanitizer.hashUsername("tester")); - } - - @Test - @DisplayName("길이가 짧은 username도 원문 없이 마스킹한다") - void should_maskShortUsername_withoutExposingRawValue() { - assertThat(LogSanitizer.maskUsername("a")).isEqualTo("**"); - assertThat(LogSanitizer.maskUsername("ab")).isEqualTo("**"); - } -} diff --git a/src/test/java/com/gitranker/api/global/logging/LoggingFilterTest.java b/src/test/java/com/gitranker/api/global/logging/LoggingFilterTest.java deleted file mode 100644 index e31da2a..0000000 --- a/src/test/java/com/gitranker/api/global/logging/LoggingFilterTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.gitranker.api.global.logging; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -import java.io.IOException; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -class LoggingFilterTest { - - @AfterEach - void tearDown() { - LogContext.clear(); - } - - @Test - @DisplayName("HTTP 2xx 응답은 outcome=success 로 기록한다") - void should_logSuccessOutcome_for2xx() throws ServletException, IOException { - ListAppender appender = attachAppender(); - try { - TestableLoggingFilter filter = new TestableLoggingFilter(1_000L, 1_100L); - executeFilter(filter, 200); - - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - assertThat(mdc).containsEntry("outcome", "success"); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("HTTP 4xx 응답은 outcome=failure 로 기록한다") - void should_logFailureOutcome_for4xx() throws ServletException, IOException { - ListAppender appender = attachAppender(); - try { - TestableLoggingFilter filter = new TestableLoggingFilter(2_000L, 2_200L); - executeFilter(filter, 404); - - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - assertThat(mdc).containsEntry("outcome", "failure"); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("HTTP 5xx 응답은 outcome=failure 로 기록한다") - void should_logFailureOutcome_for5xx() throws ServletException, IOException { - ListAppender appender = attachAppender(); - try { - TestableLoggingFilter filter = new TestableLoggingFilter(3_000L, 3_150L); - executeFilter(filter, 503); - - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - assertThat(mdc).containsEntry("outcome", "failure"); - } finally { - detachAppender(appender); - } - } - - @Test - @DisplayName("지연이 임계치를 넘는 2xx 응답은 outcome=warning 으로 기록한다") - void should_logWarningOutcome_forSlowNonErrorResponse() throws ServletException, IOException { - ListAppender appender = attachAppender(); - try { - TestableLoggingFilter filter = new TestableLoggingFilter(5_000L, 15_100L); - executeFilter(filter, 204); - - Map mdc = appender.list.getFirst().getMDCPropertyMap(); - assertThat(mdc).containsEntry("outcome", "warning"); - } finally { - detachAppender(appender); - } - } - - private void executeFilter(LoggingFilter filter, int status) throws IOException, ServletException { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/test"); - request.addHeader("User-Agent", "JUnit"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - FilterChain chain = (req, res) -> ((MockHttpServletResponse) res).setStatus(status); - filter.doFilter(request, response, chain); - } - - private ListAppender attachAppender() { - Logger logger = (Logger) LoggerFactory.getLogger(LogContext.class); - ListAppender appender = new ListAppender<>(); - appender.start(); - logger.addAppender(appender); - return appender; - } - - private void detachAppender(ListAppender appender) { - Logger logger = (Logger) LoggerFactory.getLogger(LogContext.class); - logger.detachAppender(appender); - appender.stop(); - } - - private static final class TestableLoggingFilter extends LoggingFilter { - private final long[] times; - private int index; - - private TestableLoggingFilter(long... times) { - this.times = times; - } - - @Override - protected long currentTimeMillis() { - return times[index++]; - } - } -} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java b/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java deleted file mode 100644 index 17cb0dc..0000000 --- a/src/test/java/com/gitranker/api/infrastructure/github/GitHubApiErrorHandlerTest.java +++ /dev/null @@ -1,252 +0,0 @@ -package com.gitranker.api.infrastructure.github; - -import com.gitranker.api.global.error.ErrorType; -import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException; -import com.gitranker.api.global.error.exception.GitHubApiRetryableException; -import com.gitranker.api.global.error.exception.GitHubRateLimitException; -import io.netty.handler.timeout.ReadTimeoutException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.http.HttpStatus; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.WebClientRequestException; - -import java.io.IOException; -import java.net.URI; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.TimeoutException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class GitHubApiErrorHandlerTest { - - private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); - - @Mock - private GitHubApiMetrics apiMetrics; - - @InjectMocks - private GitHubApiErrorHandler errorHandler; - - /** - * @InjectMocks는 appZoneId(ZoneId)를 주입하지 못하므로 직접 생성한다. - */ - private GitHubApiErrorHandler createHandler() { - return new GitHubApiErrorHandler(ZONE, apiMetrics); - } - - @Nested - @DisplayName("handleHttpStatus") - class HandleHttpStatus { - - @Test - @DisplayName("403 응답이면 GitHubRateLimitException을 반환한다") - void should_returnRateLimitException_when_status403() { - GitHubApiErrorHandler handler = createHandler(); - long resetEpoch = Instant.now().plusSeconds(3600).getEpochSecond(); - - ClientResponse response = ClientResponse.create(HttpStatus.FORBIDDEN) - .header("x-ratelimit-reset", String.valueOf(resetEpoch)) - .build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isInstanceOf(GitHubRateLimitException.class); - GitHubRateLimitException rateLimitEx = (GitHubRateLimitException) result; - assertThat(rateLimitEx.getResetAt()).isNotNull(); - verify(apiMetrics).recordRateLimitExceeded(); - } - - @Test - @DisplayName("429 응답이면 GitHubRateLimitException을 반환한다") - void should_returnRateLimitException_when_status429() { - GitHubApiErrorHandler handler = createHandler(); - long resetEpoch = Instant.now().plusSeconds(3600).getEpochSecond(); - - ClientResponse response = ClientResponse.create(HttpStatus.TOO_MANY_REQUESTS) - .header("x-ratelimit-reset", String.valueOf(resetEpoch)) - .build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isInstanceOf(GitHubRateLimitException.class); - verify(apiMetrics).recordRateLimitExceeded(); - } - - @Test - @DisplayName("403 응답에 reset 헤더가 없으면 현재 시간 + 60분으로 fallback한다") - void should_fallbackResetTime_when_noResetHeader() { - GitHubApiErrorHandler handler = createHandler(); - LocalDateTime before = LocalDateTime.now(ZONE).plusMinutes(59); - - ClientResponse response = ClientResponse.create(HttpStatus.FORBIDDEN).build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isInstanceOf(GitHubRateLimitException.class); - GitHubRateLimitException rateLimitEx = (GitHubRateLimitException) result; - assertThat(rateLimitEx.getResetAt()).isAfter(before); - } - - @Test - @DisplayName("4xx 응답(403 제외)이면 CLIENT_ERROR 타입의 RetryableException을 반환한다") - void should_returnClientError_when_status4xx() { - GitHubApiErrorHandler handler = createHandler(); - - ClientResponse response = ClientResponse.create(HttpStatus.BAD_REQUEST).build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isInstanceOf(GitHubApiRetryableException.class); - GitHubApiRetryableException retryableEx = (GitHubApiRetryableException) result; - assertThat(retryableEx.getErrorType()).isEqualTo(ErrorType.GITHUB_API_CLIENT_ERROR); - verify(apiMetrics).recordFailure(); - } - - @Test - @DisplayName("5xx 응답이면 SERVER_ERROR 타입의 RetryableException을 반환한다") - void should_returnServerError_when_status5xx() { - GitHubApiErrorHandler handler = createHandler(); - - ClientResponse response = ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR).build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isInstanceOf(GitHubApiRetryableException.class); - GitHubApiRetryableException retryableEx = (GitHubApiRetryableException) result; - assertThat(retryableEx.getErrorType()).isEqualTo(ErrorType.GITHUB_API_SERVER_ERROR); - verify(apiMetrics).recordFailure(); - } - - @Test - @DisplayName("2xx 응답이면 null을 반환한다") - void should_returnNull_when_status2xx() { - GitHubApiErrorHandler handler = createHandler(); - - ClientResponse response = ClientResponse.create(HttpStatus.OK).build(); - - RuntimeException result = handler.handleHttpStatus(response); - - assertThat(result).isNull(); - } - } - - @Nested - @DisplayName("handleGraphQLErrors") - class HandleGraphQLErrors { - - @Test - @DisplayName("에러 리스트가 null이면 예외를 던지지 않는다") - void should_doNothing_when_errorsNull() { - GitHubApiErrorHandler handler = createHandler(); - - handler.handleGraphQLErrors(null); - // 예외 없이 정상 종료 - } - - @Test - @DisplayName("에러 리스트가 비어있으면 예외를 던지지 않는다") - void should_doNothing_when_errorsEmpty() { - GitHubApiErrorHandler handler = createHandler(); - - handler.handleGraphQLErrors(Collections.emptyList()); - // 예외 없이 정상 종료 - } - - @Test - @DisplayName("사용자를 찾을 수 없는 에러면 NonRetryableException을 던진다") - void should_throwNonRetryable_when_userNotFound() { - GitHubApiErrorHandler handler = createHandler(); - - List errors = List.of("Could not resolve to a User with the login of 'unknown'"); - - assertThatThrownBy(() -> handler.handleGraphQLErrors(errors)) - .isInstanceOf(GitHubApiNonRetryableException.class) - .extracting(e -> ((GitHubApiNonRetryableException) e).getErrorType()) - .isEqualTo(ErrorType.GITHUB_USER_NOT_FOUND); - } - - @Test - @DisplayName("기타 GraphQL 에러면 PARTIAL_ERROR 타입의 RetryableException을 던진다") - void should_throwRetryable_when_otherGraphQLError() { - GitHubApiErrorHandler handler = createHandler(); - - List errors = List.of("Something went wrong"); - - assertThatThrownBy(() -> handler.handleGraphQLErrors(errors)) - .isInstanceOf(GitHubApiRetryableException.class) - .extracting(e -> ((GitHubApiRetryableException) e).getErrorType()) - .isEqualTo(ErrorType.GITHUB_PARTIAL_ERROR); - } - } - - @Nested - @DisplayName("handleTimeout / handleReadTimeout / handleIOException / handleNetworkError") - class HandleOtherErrors { - - @Test - @DisplayName("TimeoutException이면 TIMEOUT 타입의 RetryableException을 반환한다") - void should_returnTimeout_when_timeoutException() { - GitHubApiErrorHandler handler = createHandler(); - - GitHubApiRetryableException result = handler.handleTimeout( - new TimeoutException("timed out"), Duration.ofSeconds(30)); - - assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT); - assertThat(result.getCause()).isInstanceOf(TimeoutException.class); - } - - @Test - @DisplayName("ReadTimeoutException이면 TIMEOUT 타입의 RetryableException을 반환한다") - void should_returnTimeout_when_readTimeoutException() { - GitHubApiErrorHandler handler = createHandler(); - - GitHubApiRetryableException result = handler.handleReadTimeout( - ReadTimeoutException.INSTANCE); - - assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT); - } - - @Test - @DisplayName("IOException이면 API_ERROR 타입의 RetryableException을 반환한다") - void should_returnApiError_when_ioException() { - GitHubApiErrorHandler handler = createHandler(); - - GitHubApiRetryableException result = handler.handleIOException( - new IOException("connection reset")); - - assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR); - assertThat(result.getCause()).isInstanceOf(IOException.class); - } - - @Test - @DisplayName("WebClientRequestException이면 API_ERROR 타입의 RetryableException을 반환한다") - void should_returnApiError_when_networkError() { - GitHubApiErrorHandler handler = createHandler(); - - WebClientRequestException networkEx = new WebClientRequestException( - new IOException("connection refused"), - org.springframework.http.HttpMethod.POST, - URI.create("https://api.github.com/graphql"), - org.springframework.http.HttpHeaders.EMPTY); - - GitHubApiRetryableException result = handler.handleNetworkError(networkEx); - - assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR); - assertThat(result.getCause()).isInstanceOf(WebClientRequestException.class); - } - } -} diff --git a/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java b/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java deleted file mode 100644 index 228df2c..0000000 --- a/src/test/java/com/gitranker/api/infrastructure/github/token/GitHubTokenPoolTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.gitranker.api.infrastructure.github.token; - -import com.gitranker.api.global.error.exception.GitHubRateLimitExhaustedException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class GitHubTokenPoolTest { - - private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); - private static final int THRESHOLD = 10; - - private GitHubTokenPool createPool(String tokensConfig) { - return new GitHubTokenPool(tokensConfig, THRESHOLD, ZONE); - } - - @Test - @DisplayName("단일 토큰 설정 시 해당 토큰을 반환한다") - void should_returnToken_when_singleTokenConfigured() { - GitHubTokenPool pool = createPool("ghp_token1"); - - assertThat(pool.getToken()).isEqualTo("ghp_token1"); - } - - @Test - @DisplayName("쉼표로 구분된 여러 토큰을 파싱한다") - void should_returnFirstToken_when_multipleTokensConfigured() { - GitHubTokenPool pool = createPool("ghp_token1, ghp_token2, ghp_token3"); - - assertThat(pool.getToken()).isEqualTo("ghp_token1"); - } - - @Test - @DisplayName("빈 토큰 설정이면 예외가 발생한다") - void should_throwException_when_tokensConfigIsBlank() { - assertThatThrownBy(() -> createPool("")) - .isInstanceOf(IllegalStateException.class); - } - - @Test - @DisplayName("null 토큰 설정이면 예외가 발생한다") - void should_throwException_when_tokensConfigIsNull() { - assertThatThrownBy(() -> new GitHubTokenPool(null, THRESHOLD, ZONE)) - .isInstanceOf(IllegalStateException.class); - } - - @Test - @DisplayName("현재 토큰이 Rate Limit에 걸리면 다음 토큰으로 로테이션한다") - void should_rotateToNextToken_when_currentTokenRateLimited() { - GitHubTokenPool pool = createPool("ghp_token1, ghp_token2"); - - // token1의 remaining을 threshold 이하로 설정 → 사용 불가 - pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1)); - - assertThat(pool.getToken()).isEqualTo("ghp_token2"); - } - - @Test - @DisplayName("모든 토큰이 소진되면 GitHubRateLimitExhaustedException이 발생한다") - void should_throwExhausted_when_allTokensRateLimited() { - GitHubTokenPool pool = createPool("ghp_token1, ghp_token2"); - - pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1)); - pool.updateTokenState("ghp_token2", 3, LocalDateTime.now(ZONE).plusHours(1)); - - assertThatThrownBy(pool::getToken) - .isInstanceOf(GitHubRateLimitExhaustedException.class); - } - - @Test - @DisplayName("토큰 상태 갱신 후에도 remaining이 threshold보다 크면 사용 가능하다") - void should_remainAvailable_when_remainingAboveThreshold() { - GitHubTokenPool pool = createPool("ghp_token1"); - - pool.updateTokenState("ghp_token1", 100, LocalDateTime.now(ZONE).plusHours(1)); - - assertThat(pool.getToken()).isEqualTo("ghp_token1"); - } - - @Test - @DisplayName("로테이션 후 다시 요청하면 마지막으로 사용한 토큰부터 탐색한다") - void should_startFromLastUsedIndex_when_gettingTokenAfterRotation() { - GitHubTokenPool pool = createPool("ghp_token1, ghp_token2, ghp_token3"); - - // token1 소진 → token2로 로테이션 - pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1)); - assertThat(pool.getToken()).isEqualTo("ghp_token2"); - - // 다음 호출도 token2부터 시작 (token2가 아직 사용 가능하므로 token2 반환) - assertThat(pool.getToken()).isEqualTo("ghp_token2"); - } -} diff --git a/src/test/resources/application-openapi.yml b/src/test/resources/application-openapi.yml deleted file mode 100644 index 761ee61..0000000 --- a/src/test/resources/application-openapi.yml +++ /dev/null @@ -1,49 +0,0 @@ -spring: - config: - activate: - on-profile: openapi - - datasource: - url: jdbc:h2:mem:openapi;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE - username: sa - password: - driver-class-name: org.h2.Driver - - jpa: - hibernate: - ddl-auto: none - properties: - hibernate: - dialect: org.hibernate.dialect.H2Dialect - - security: - oauth2: - client: - registration: - github: - client-id: openapi-client - client-secret: openapi-secret - scope: - - read:user - - user:email - redirect-uri: http://localhost/login/oauth2/code/github - -jwt: - secret: b3BlbmFwaS10ZXN0LXNlY3JldC1vcGVuYXBpLXRlc3Qtc2VjcmV0LW9wZW5hcGktdGVzdC1zZWNyZXQtb3BlbmFwaS10ZXN0LXNlY3JldC0= - access-token-expiration: 3600000 - refresh-token-expiration: 1209600000 - -github: - api: - graphql-url: https://api.github.com/graphql - tokens: openapi-token - threshold: 100 - -app: - cors: - allowed-origins: http://localhost:3000 - oauth2: - authorized-redirect-uri: http://localhost:3000/auth/callback - cookie: - domain: localhost - secure: false