Skip to content

Commit d4e144c

Browse files
authored
(#55) GitHub API 인프라 단위 테스트 추가
GitHubTokenPool 토큰 로테이션·소진 시나리오 8개, GitHubApiErrorHandler HTTP 상태·GraphQL 에러·타임아웃 분류 16개 테스트 작성.
1 parent be27266 commit d4e144c

2 files changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package com.gitranker.api.infrastructure.github;
2+
3+
import com.gitranker.api.global.error.ErrorType;
4+
import com.gitranker.api.global.error.exception.GitHubApiNonRetryableException;
5+
import com.gitranker.api.global.error.exception.GitHubApiRetryableException;
6+
import com.gitranker.api.global.error.exception.GitHubRateLimitException;
7+
import io.netty.handler.timeout.ReadTimeoutException;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Nested;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.api.extension.ExtendWith;
12+
import org.mockito.InjectMocks;
13+
import org.mockito.Mock;
14+
import org.mockito.junit.jupiter.MockitoExtension;
15+
import org.springframework.http.HttpStatus;
16+
import org.springframework.web.reactive.function.client.ClientResponse;
17+
import org.springframework.web.reactive.function.client.WebClientRequestException;
18+
19+
import java.io.IOException;
20+
import java.net.URI;
21+
import java.time.Duration;
22+
import java.time.Instant;
23+
import java.time.LocalDateTime;
24+
import java.time.ZoneId;
25+
import java.util.Collections;
26+
import java.util.List;
27+
import java.util.concurrent.TimeoutException;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
31+
import static org.mockito.Mockito.*;
32+
33+
@ExtendWith(MockitoExtension.class)
34+
class GitHubApiErrorHandlerTest {
35+
36+
private static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
37+
38+
@Mock
39+
private GitHubApiMetrics apiMetrics;
40+
41+
@InjectMocks
42+
private GitHubApiErrorHandler errorHandler;
43+
44+
/**
45+
* @InjectMocks는 appZoneId(ZoneId)를 주입하지 못하므로 직접 생성한다.
46+
*/
47+
private GitHubApiErrorHandler createHandler() {
48+
return new GitHubApiErrorHandler(ZONE, apiMetrics);
49+
}
50+
51+
@Nested
52+
@DisplayName("handleHttpStatus")
53+
class HandleHttpStatus {
54+
55+
@Test
56+
@DisplayName("403 응답이면 GitHubRateLimitException을 반환한다")
57+
void should_returnRateLimitException_when_status403() {
58+
GitHubApiErrorHandler handler = createHandler();
59+
long resetEpoch = Instant.now().plusSeconds(3600).getEpochSecond();
60+
61+
ClientResponse response = ClientResponse.create(HttpStatus.FORBIDDEN)
62+
.header("x-ratelimit-reset", String.valueOf(resetEpoch))
63+
.build();
64+
65+
RuntimeException result = handler.handleHttpStatus(response);
66+
67+
assertThat(result).isInstanceOf(GitHubRateLimitException.class);
68+
GitHubRateLimitException rateLimitEx = (GitHubRateLimitException) result;
69+
assertThat(rateLimitEx.getResetAt()).isNotNull();
70+
verify(apiMetrics).recordRateLimitExceeded();
71+
}
72+
73+
@Test
74+
@DisplayName("429 응답이면 GitHubRateLimitException을 반환한다")
75+
void should_returnRateLimitException_when_status429() {
76+
GitHubApiErrorHandler handler = createHandler();
77+
long resetEpoch = Instant.now().plusSeconds(3600).getEpochSecond();
78+
79+
ClientResponse response = ClientResponse.create(HttpStatus.TOO_MANY_REQUESTS)
80+
.header("x-ratelimit-reset", String.valueOf(resetEpoch))
81+
.build();
82+
83+
RuntimeException result = handler.handleHttpStatus(response);
84+
85+
assertThat(result).isInstanceOf(GitHubRateLimitException.class);
86+
verify(apiMetrics).recordRateLimitExceeded();
87+
}
88+
89+
@Test
90+
@DisplayName("403 응답에 reset 헤더가 없으면 현재 시간 + 60분으로 fallback한다")
91+
void should_fallbackResetTime_when_noResetHeader() {
92+
GitHubApiErrorHandler handler = createHandler();
93+
LocalDateTime before = LocalDateTime.now(ZONE).plusMinutes(59);
94+
95+
ClientResponse response = ClientResponse.create(HttpStatus.FORBIDDEN).build();
96+
97+
RuntimeException result = handler.handleHttpStatus(response);
98+
99+
assertThat(result).isInstanceOf(GitHubRateLimitException.class);
100+
GitHubRateLimitException rateLimitEx = (GitHubRateLimitException) result;
101+
assertThat(rateLimitEx.getResetAt()).isAfter(before);
102+
}
103+
104+
@Test
105+
@DisplayName("4xx 응답(403 제외)이면 CLIENT_ERROR 타입의 RetryableException을 반환한다")
106+
void should_returnClientError_when_status4xx() {
107+
GitHubApiErrorHandler handler = createHandler();
108+
109+
ClientResponse response = ClientResponse.create(HttpStatus.BAD_REQUEST).build();
110+
111+
RuntimeException result = handler.handleHttpStatus(response);
112+
113+
assertThat(result).isInstanceOf(GitHubApiRetryableException.class);
114+
GitHubApiRetryableException retryableEx = (GitHubApiRetryableException) result;
115+
assertThat(retryableEx.getErrorType()).isEqualTo(ErrorType.GITHUB_API_CLIENT_ERROR);
116+
verify(apiMetrics).recordFailure();
117+
}
118+
119+
@Test
120+
@DisplayName("5xx 응답이면 SERVER_ERROR 타입의 RetryableException을 반환한다")
121+
void should_returnServerError_when_status5xx() {
122+
GitHubApiErrorHandler handler = createHandler();
123+
124+
ClientResponse response = ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR).build();
125+
126+
RuntimeException result = handler.handleHttpStatus(response);
127+
128+
assertThat(result).isInstanceOf(GitHubApiRetryableException.class);
129+
GitHubApiRetryableException retryableEx = (GitHubApiRetryableException) result;
130+
assertThat(retryableEx.getErrorType()).isEqualTo(ErrorType.GITHUB_API_SERVER_ERROR);
131+
verify(apiMetrics).recordFailure();
132+
}
133+
134+
@Test
135+
@DisplayName("2xx 응답이면 null을 반환한다")
136+
void should_returnNull_when_status2xx() {
137+
GitHubApiErrorHandler handler = createHandler();
138+
139+
ClientResponse response = ClientResponse.create(HttpStatus.OK).build();
140+
141+
RuntimeException result = handler.handleHttpStatus(response);
142+
143+
assertThat(result).isNull();
144+
}
145+
}
146+
147+
@Nested
148+
@DisplayName("handleGraphQLErrors")
149+
class HandleGraphQLErrors {
150+
151+
@Test
152+
@DisplayName("에러 리스트가 null이면 예외를 던지지 않는다")
153+
void should_doNothing_when_errorsNull() {
154+
GitHubApiErrorHandler handler = createHandler();
155+
156+
handler.handleGraphQLErrors(null);
157+
// 예외 없이 정상 종료
158+
}
159+
160+
@Test
161+
@DisplayName("에러 리스트가 비어있으면 예외를 던지지 않는다")
162+
void should_doNothing_when_errorsEmpty() {
163+
GitHubApiErrorHandler handler = createHandler();
164+
165+
handler.handleGraphQLErrors(Collections.emptyList());
166+
// 예외 없이 정상 종료
167+
}
168+
169+
@Test
170+
@DisplayName("사용자를 찾을 수 없는 에러면 NonRetryableException을 던진다")
171+
void should_throwNonRetryable_when_userNotFound() {
172+
GitHubApiErrorHandler handler = createHandler();
173+
174+
List<Object> errors = List.of("Could not resolve to a User with the login of 'unknown'");
175+
176+
assertThatThrownBy(() -> handler.handleGraphQLErrors(errors))
177+
.isInstanceOf(GitHubApiNonRetryableException.class)
178+
.extracting(e -> ((GitHubApiNonRetryableException) e).getErrorType())
179+
.isEqualTo(ErrorType.GITHUB_USER_NOT_FOUND);
180+
}
181+
182+
@Test
183+
@DisplayName("기타 GraphQL 에러면 PARTIAL_ERROR 타입의 RetryableException을 던진다")
184+
void should_throwRetryable_when_otherGraphQLError() {
185+
GitHubApiErrorHandler handler = createHandler();
186+
187+
List<Object> errors = List.of("Something went wrong");
188+
189+
assertThatThrownBy(() -> handler.handleGraphQLErrors(errors))
190+
.isInstanceOf(GitHubApiRetryableException.class)
191+
.extracting(e -> ((GitHubApiRetryableException) e).getErrorType())
192+
.isEqualTo(ErrorType.GITHUB_PARTIAL_ERROR);
193+
}
194+
}
195+
196+
@Nested
197+
@DisplayName("handleTimeout / handleReadTimeout / handleIOException / handleNetworkError")
198+
class HandleOtherErrors {
199+
200+
@Test
201+
@DisplayName("TimeoutException이면 TIMEOUT 타입의 RetryableException을 반환한다")
202+
void should_returnTimeout_when_timeoutException() {
203+
GitHubApiErrorHandler handler = createHandler();
204+
205+
GitHubApiRetryableException result = handler.handleTimeout(
206+
new TimeoutException("timed out"), Duration.ofSeconds(30));
207+
208+
assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT);
209+
assertThat(result.getCause()).isInstanceOf(TimeoutException.class);
210+
}
211+
212+
@Test
213+
@DisplayName("ReadTimeoutException이면 TIMEOUT 타입의 RetryableException을 반환한다")
214+
void should_returnTimeout_when_readTimeoutException() {
215+
GitHubApiErrorHandler handler = createHandler();
216+
217+
GitHubApiRetryableException result = handler.handleReadTimeout(
218+
ReadTimeoutException.INSTANCE);
219+
220+
assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_TIMEOUT);
221+
}
222+
223+
@Test
224+
@DisplayName("IOException이면 API_ERROR 타입의 RetryableException을 반환한다")
225+
void should_returnApiError_when_ioException() {
226+
GitHubApiErrorHandler handler = createHandler();
227+
228+
GitHubApiRetryableException result = handler.handleIOException(
229+
new IOException("connection reset"));
230+
231+
assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR);
232+
assertThat(result.getCause()).isInstanceOf(IOException.class);
233+
}
234+
235+
@Test
236+
@DisplayName("WebClientRequestException이면 API_ERROR 타입의 RetryableException을 반환한다")
237+
void should_returnApiError_when_networkError() {
238+
GitHubApiErrorHandler handler = createHandler();
239+
240+
WebClientRequestException networkEx = new WebClientRequestException(
241+
new IOException("connection refused"),
242+
org.springframework.http.HttpMethod.POST,
243+
URI.create("https://api.github.com/graphql"),
244+
org.springframework.http.HttpHeaders.EMPTY);
245+
246+
GitHubApiRetryableException result = handler.handleNetworkError(networkEx);
247+
248+
assertThat(result.getErrorType()).isEqualTo(ErrorType.GITHUB_API_ERROR);
249+
assertThat(result.getCause()).isInstanceOf(WebClientRequestException.class);
250+
}
251+
}
252+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.gitranker.api.infrastructure.github.token;
2+
3+
import com.gitranker.api.global.error.exception.GitHubRateLimitExhaustedException;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
7+
import java.time.LocalDateTime;
8+
import java.time.ZoneId;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
12+
13+
class GitHubTokenPoolTest {
14+
15+
private static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
16+
private static final int THRESHOLD = 10;
17+
18+
private GitHubTokenPool createPool(String tokensConfig) {
19+
return new GitHubTokenPool(tokensConfig, THRESHOLD, ZONE);
20+
}
21+
22+
@Test
23+
@DisplayName("단일 토큰 설정 시 해당 토큰을 반환한다")
24+
void should_returnToken_when_singleTokenConfigured() {
25+
GitHubTokenPool pool = createPool("ghp_token1");
26+
27+
assertThat(pool.getToken()).isEqualTo("ghp_token1");
28+
}
29+
30+
@Test
31+
@DisplayName("쉼표로 구분된 여러 토큰을 파싱한다")
32+
void should_returnFirstToken_when_multipleTokensConfigured() {
33+
GitHubTokenPool pool = createPool("ghp_token1, ghp_token2, ghp_token3");
34+
35+
assertThat(pool.getToken()).isEqualTo("ghp_token1");
36+
}
37+
38+
@Test
39+
@DisplayName("빈 토큰 설정이면 예외가 발생한다")
40+
void should_throwException_when_tokensConfigIsBlank() {
41+
assertThatThrownBy(() -> createPool(""))
42+
.isInstanceOf(IllegalStateException.class);
43+
}
44+
45+
@Test
46+
@DisplayName("null 토큰 설정이면 예외가 발생한다")
47+
void should_throwException_when_tokensConfigIsNull() {
48+
assertThatThrownBy(() -> new GitHubTokenPool(null, THRESHOLD, ZONE))
49+
.isInstanceOf(IllegalStateException.class);
50+
}
51+
52+
@Test
53+
@DisplayName("현재 토큰이 Rate Limit에 걸리면 다음 토큰으로 로테이션한다")
54+
void should_rotateToNextToken_when_currentTokenRateLimited() {
55+
GitHubTokenPool pool = createPool("ghp_token1, ghp_token2");
56+
57+
// token1의 remaining을 threshold 이하로 설정 → 사용 불가
58+
pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1));
59+
60+
assertThat(pool.getToken()).isEqualTo("ghp_token2");
61+
}
62+
63+
@Test
64+
@DisplayName("모든 토큰이 소진되면 GitHubRateLimitExhaustedException이 발생한다")
65+
void should_throwExhausted_when_allTokensRateLimited() {
66+
GitHubTokenPool pool = createPool("ghp_token1, ghp_token2");
67+
68+
pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1));
69+
pool.updateTokenState("ghp_token2", 3, LocalDateTime.now(ZONE).plusHours(1));
70+
71+
assertThatThrownBy(pool::getToken)
72+
.isInstanceOf(GitHubRateLimitExhaustedException.class);
73+
}
74+
75+
@Test
76+
@DisplayName("토큰 상태 갱신 후에도 remaining이 threshold보다 크면 사용 가능하다")
77+
void should_remainAvailable_when_remainingAboveThreshold() {
78+
GitHubTokenPool pool = createPool("ghp_token1");
79+
80+
pool.updateTokenState("ghp_token1", 100, LocalDateTime.now(ZONE).plusHours(1));
81+
82+
assertThat(pool.getToken()).isEqualTo("ghp_token1");
83+
}
84+
85+
@Test
86+
@DisplayName("로테이션 후 다시 요청하면 마지막으로 사용한 토큰부터 탐색한다")
87+
void should_startFromLastUsedIndex_when_gettingTokenAfterRotation() {
88+
GitHubTokenPool pool = createPool("ghp_token1, ghp_token2, ghp_token3");
89+
90+
// token1 소진 → token2로 로테이션
91+
pool.updateTokenState("ghp_token1", 5, LocalDateTime.now(ZONE).plusHours(1));
92+
assertThat(pool.getToken()).isEqualTo("ghp_token2");
93+
94+
// 다음 호출도 token2부터 시작 (token2가 아직 사용 가능하므로 token2 반환)
95+
assertThat(pool.getToken()).isEqualTo("ghp_token2");
96+
}
97+
}

0 commit comments

Comments
 (0)