From e326ec67e3e67396c1758d3a9b9f6775da42dac6 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 09:49:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Spotify=20AccessToken=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandler.java | 45 ++++++++ .../global/spotify/SpotifyClient.java | 106 ++++++++++++++++-- 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java index 422d65e9..608d811c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java @@ -1,14 +1,20 @@ package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; // 429 에러 처리 및 전역 쿨다운 관리 +// 401 에러 처리 (토큰 만료 시 자동 재발급) @Slf4j @Component +@RequiredArgsConstructor public class SpotifyRateLimitHandler { + private final SpotifyClient spotifyClient; + private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회 private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초 @@ -65,6 +71,22 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str globalConsecutive429Count = 0; return result; } catch (Exception e) { + // 401 에러 확인 (Unauthorized - 토큰 만료) + boolean is401 = is401Error(e); + + if (is401) { + log.warn("401 Unauthorized 에러 감지 - 토큰 재발급 후 재시도: attempt={}/{}", attempt, maxRetry); + // 토큰 강제 재발급 + spotifyClient.forceRefreshToken(); + + if (attempt < maxRetry) { + // 재시도 + continue; + } else { + log.error("401 에러 재시도 횟수 초과 ({}회)", maxRetry); + throw new RuntimeException(context + ": 401 Unauthorized - 토큰 재발급 후에도 실패", e); + } + } // 원본 예외 확인 (래핑된 경우 cause 확인) Throwable originalException = e; Throwable current = e; @@ -203,5 +225,28 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str throw new RuntimeException(context + ": rate limit retry exhausted"); } + + /** + * 401 Unauthorized 에러인지 확인 + */ + private boolean is401Error(Throwable e) { + Throwable current = e; + while (current != null) { + String className = current.getClass().getSimpleName(); + String errorMsg = current.getMessage(); + + // 401 에러 확인 + if (className.contains("Unauthorized") || + className.contains("401") || + (errorMsg != null && (errorMsg.contains("401") || + errorMsg.contains("Unauthorized") || + errorMsg.contains("Invalid access token")))) { + return true; + } + + current = current.getCause(); + } + return false; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java index 86c85e19..f21744e4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java @@ -1,32 +1,122 @@ package com.back.web7_9_codecrete_be.global.spotify; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import se.michaelthelin.spotify.SpotifyApi; import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials; import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Spotify AccessToken 중앙 관리 클래스 + * + * - 토큰 만료 시간 관리 (1시간 유효) + * - 만료 전 자동 재발급 (5분 여유) + * - 401 에러 발생 시 자동 재발급 및 재요청 지원 + */ +@Slf4j @Component @RequiredArgsConstructor public class SpotifyClient { private final SpotifyApi spotifyApi; - - public String getAccessToken() { + + // 토큰 관리 필드 + private volatile String cachedAccessToken; + private volatile long tokenExpiresAt = 0; // 만료 시각 (밀리초) + private final ReentrantLock tokenLock = new ReentrantLock(); + + // 토큰 만료 여유 시간 (5분 = 300초) + private static final long TOKEN_BUFFER_SECONDS = 300; + private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000; + + /** + * AccessToken 발급 (내부 메서드) + * 동시성 제어를 통해 중복 발급 방지 + */ + private String refreshAccessToken() { + tokenLock.lock(); try { + // Double-check: 다른 스레드가 이미 토큰을 발급했는지 확인 + if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) { + return cachedAccessToken; + } + + log.info("Spotify AccessToken 발급 시작"); ClientCredentialsRequest request = spotifyApi.clientCredentials().build(); ClientCredentials credentials = request.execute(); - spotifyApi.setAccessToken(credentials.getAccessToken()); - return credentials.getAccessToken(); + + String newToken = credentials.getAccessToken(); + // Spotify 토큰은 3600초(1시간) 유효 + // 여유 시간을 빼서 실제 만료 시각 계산 + long expiresInSeconds = credentials.getExpiresIn(); + tokenExpiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000) - TOKEN_BUFFER_MS; + + cachedAccessToken = newToken; + spotifyApi.setAccessToken(newToken); + + log.info("Spotify AccessToken 발급 완료. 만료 시각: {} ({}초 후)", + new java.util.Date(tokenExpiresAt), expiresInSeconds); + + return newToken; } catch (Exception e) { + log.error("Spotify 토큰 발급 실패", e); + // 토큰 발급 실패 시 캐시 초기화 + cachedAccessToken = null; + tokenExpiresAt = 0; throw new RuntimeException("Spotify 토큰 발급 실패", e); + } finally { + tokenLock.unlock(); } } - - public SpotifyApi getAuthorizedApi() { - if (spotifyApi.getAccessToken() == null) { - getAccessToken(); + + /** + * AccessToken 조회 (만료 체크 포함) + * 만료되었거나 곧 만료될 경우 자동 재발급 + */ + public String getAccessToken() { + long now = System.currentTimeMillis(); + + // 토큰이 없거나 만료되었거나 곧 만료될 경우 재발급 + if (cachedAccessToken == null || now >= tokenExpiresAt) { + return refreshAccessToken(); } + + return cachedAccessToken; + } + + /** + * 인증된 SpotifyApi 반환 + * 토큰이 없거나 만료된 경우 자동으로 재발급 + */ + public SpotifyApi getAuthorizedApi() { + getAccessToken(); // 만료 체크 및 필요시 재발급 return spotifyApi; } + + /** + * 401 에러 발생 시 토큰 강제 재발급 + * 이 메서드를 호출한 후 API를 재요청해야 함 + */ + public void forceRefreshToken() { + log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급"); + tokenLock.lock(); + try { + // 캐시 초기화 후 재발급 + cachedAccessToken = null; + tokenExpiresAt = 0; + refreshAccessToken(); + } finally { + tokenLock.unlock(); + } + } + + /** + * 토큰 만료 여부 확인 + */ + public boolean isTokenExpired() { + return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt; + } } From 3e81b1b5330d6a456fc9cd3cb4fa5f137f0ae526 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 10:10:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandler.java | 4 +- .../global/spotify/SpotifyClient.java | 39 ++++++------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java index 608d811c..9b1b51d0 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java @@ -226,9 +226,7 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str throw new RuntimeException(context + ": rate limit retry exhausted"); } - /** - * 401 Unauthorized 에러인지 확인 - */ + // 401 에러인지 확인 private boolean is401Error(Throwable e) { Throwable current = e; while (current != null) { diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java index f21744e4..e4b5f1f4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java @@ -9,13 +9,12 @@ import java.util.concurrent.locks.ReentrantLock; -/** - * Spotify AccessToken 중앙 관리 클래스 - * - * - 토큰 만료 시간 관리 (1시간 유효) - * - 만료 전 자동 재발급 (5분 여유) - * - 401 에러 발생 시 자동 재발급 및 재요청 지원 - */ +// Spotify AccessToken 중앙 관리 클래스 + +// 토큰 만료 시간 관리 (1시간 유효) +// 만료 전 자동 재발급 (5분 여유) +// 401 에러 발생 시 자동 재발급 및 재요청 지원 + @Slf4j @Component @RequiredArgsConstructor @@ -32,10 +31,7 @@ public class SpotifyClient { private static final long TOKEN_BUFFER_SECONDS = 300; private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000; - /** - * AccessToken 발급 (내부 메서드) - * 동시성 제어를 통해 중복 발급 방지 - */ + // AccessToken 발급 private String refreshAccessToken() { tokenLock.lock(); try { @@ -72,10 +68,7 @@ private String refreshAccessToken() { } } - /** - * AccessToken 조회 (만료 체크 포함) - * 만료되었거나 곧 만료될 경우 자동 재발급 - */ + // AccessToken 조회 (만료 체크 포함) - 만료되었거나 곧 만료될 경우 자동 재발급 public String getAccessToken() { long now = System.currentTimeMillis(); @@ -87,19 +80,13 @@ public String getAccessToken() { return cachedAccessToken; } - /** - * 인증된 SpotifyApi 반환 - * 토큰이 없거나 만료된 경우 자동으로 재발급 - */ + // 인증된 SpotifyApi 반환 - 토큰이 없거나 만료된 경우 자동으로 재발급 public SpotifyApi getAuthorizedApi() { getAccessToken(); // 만료 체크 및 필요시 재발급 return spotifyApi; } - - /** - * 401 에러 발생 시 토큰 강제 재발급 - * 이 메서드를 호출한 후 API를 재요청해야 함 - */ + + // 401 에러 발생 시 토큰 강제 재발급 (SpotifyRateLimitHandler에서 자동 호출) public void forceRefreshToken() { log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급"); tokenLock.lock(); @@ -113,9 +100,7 @@ public void forceRefreshToken() { } } - /** - * 토큰 만료 여부 확인 - */ + // 토큰 만료 여부 확인 public boolean isTokenExpired() { return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt; } From 6909477b386a763771641f3ed9c2d36b647e51e1 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 10:16:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandlerTest.java | 119 +++++++++ .../global/spotify/SpotifyClientTest.java | 227 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java create mode 100644 src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java new file mode 100644 index 00000000..faf1b992 --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java @@ -0,0 +1,119 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; + +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import org.junit.jupiter.api.BeforeEach; +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.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SpotifyRateLimitHandler 401 에러 처리 테스트") +class SpotifyRateLimitHandlerTest { + + @Mock + private SpotifyClient spotifyClient; + + @InjectMocks + private SpotifyRateLimitHandler rateLimitHandler; + + @BeforeEach + void setUp() { + // 각 테스트 전에 상태 초기화 + } + + @Test + @DisplayName("401 에러 발생 시 토큰 재발급 후 재시도 성공") + void callWithRateLimitRetry_when401Error_shouldRefreshTokenAndRetry() { + // given + RuntimeException unauthorizedException = new RuntimeException("401 Unauthorized"); + String successResult = "success-result"; + + // forceRefreshToken은 void 메서드이므로 doNothing() 사용 + doNothing().when(spotifyClient).forceRefreshToken(); + + // 첫 번째 호출은 401 에러, 두 번째 호출은 성공하도록 설정 + AtomicInteger callCount = new AtomicInteger(0); + + // when + String result = rateLimitHandler.callWithRateLimitRetry( + () -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + // 첫 번째 호출 시 401 에러 + throw unauthorizedException; + } + // 두 번째 호출 시 성공 + return successResult; + }, + "test-context" + ); + + // then + assertThat(result).isEqualTo(successResult); + assertThat(callCount.get()).isEqualTo(2); // 재시도로 2번 호출됨 + verify(spotifyClient, times(1)).forceRefreshToken(); + } + + @Test + @DisplayName("401 에러가 아닌 경우 토큰 재발급하지 않음") + void callWithRateLimitRetry_whenNot401Error_shouldNotRefreshToken() { + // given + RuntimeException otherException = new RuntimeException("500 Internal Server Error"); + String successResult = "success-result"; + + // when & then + assertThatThrownBy(() -> { + rateLimitHandler.callWithRateLimitRetry( + () -> { + throw otherException; + }, + "test-context" + ); + }).isInstanceOf(RuntimeException.class) + .hasMessageContaining("500 Internal Server Error"); + + // 401이 아니면 토큰 재발급하지 않아야 함 + verify(spotifyClient, never()).forceRefreshToken(); + } + + @Test + @DisplayName("정상 호출 시 토큰 재발급하지 않음") + void callWithRateLimitRetry_whenSuccess_shouldNotRefreshToken() { + // given + String successResult = "success-result"; + + // when + String result = rateLimitHandler.callWithRateLimitRetry( + () -> successResult, + "test-context" + ); + + // then + assertThat(result).isEqualTo(successResult); + verify(spotifyClient, never()).forceRefreshToken(); + } + + @Test + @DisplayName("401 에러가 예외 체인에 있는 경우 감지") + void is401Error_when401InExceptionChain_shouldDetect() { + // given + RuntimeException cause = new RuntimeException("401 Unauthorized"); + RuntimeException wrapper = new RuntimeException("Wrapped exception", cause); + + // when + // is401Error는 private 메서드이므로 callWithRateLimitRetry를 통해 간접 테스트 + // 실제로는 401 에러가 발생하면 forceRefreshToken이 호출되어야 함 + // 이 테스트는 통합 테스트에서 더 적합할 수 있음 + } +} + diff --git a/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java b/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java new file mode 100644 index 00000000..e7e4a14e --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java @@ -0,0 +1,227 @@ +package com.back.web7_9_codecrete_be.global.spotify; + +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.test.util.ReflectionTestUtils; +import se.michaelthelin.spotify.SpotifyApi; +import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials; +import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SpotifyClient 토큰 관리 테스트") +class SpotifyClientTest { + + @Mock + private SpotifyApi spotifyApi; + + @Mock + private ClientCredentialsRequest.Builder requestBuilder; + + @Mock + private ClientCredentialsRequest request; + + @InjectMocks + private SpotifyClient spotifyClient; + + @BeforeEach + void setUp() { + // ReflectionTestUtils로 private 필드 초기화 + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", null); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", 0L); + } + + @Test + @DisplayName("토큰이 없을 때 자동 발급") + void getAccessToken_whenTokenIsNull_shouldIssueNewToken() throws Exception { + // given + String expectedToken = "new-access-token-123"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); // 1시간 + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(expectedToken); + verify(spotifyApi, times(1)).setAccessToken(expectedToken); + } + + @Test + @DisplayName("토큰이 만료되었을 때 자동 재발급") + void getAccessToken_whenTokenExpired_shouldRefreshToken() throws Exception { + // given + String oldToken = "old-token"; + String newToken = "new-token-456"; + long expiredTime = System.currentTimeMillis() - 1000; // 1초 전에 만료 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", oldToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", expiredTime); + + ClientCredentials credentials = mock(ClientCredentials.class); + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(newToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(newToken); + assertThat(token).isNotEqualTo(oldToken); + verify(spotifyApi, times(1)).setAccessToken(newToken); + } + + @Test + @DisplayName("토큰이 유효할 때 재사용") + void getAccessToken_whenTokenValid_shouldReuseToken() throws Exception { + // given + String validToken = "valid-token-789"; + long futureExpiry = System.currentTimeMillis() + 1000000; // 미래에 만료 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", validToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", futureExpiry); + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(validToken); + // 토큰이 유효하면 재발급하지 않아야 함 + verify(spotifyApi, never()).clientCredentials(); + } + + @Test + @DisplayName("forceRefreshToken은 토큰을 강제로 재발급") + void forceRefreshToken_shouldForceRefresh() throws Exception { + // given + String oldToken = "old-token"; + String newToken = "forced-new-token"; + long futureExpiry = System.currentTimeMillis() + 1000000; // 아직 유효한 토큰 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", oldToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", futureExpiry); + + ClientCredentials credentials = mock(ClientCredentials.class); + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(newToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + spotifyClient.forceRefreshToken(); + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(newToken); + assertThat(token).isNotEqualTo(oldToken); + verify(spotifyApi, times(1)).setAccessToken(newToken); + } + + @Test + @DisplayName("동시에 여러 스레드가 호출해도 토큰은 한 번만 발급") + void getAccessToken_concurrentCalls_shouldIssueTokenOnce() throws Exception { + // given + String expectedToken = "concurrent-token"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger tokenIssueCount = new AtomicInteger(0); + + // when + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + spotifyClient.getAccessToken(); + tokenIssueCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + // then + // 모든 스레드가 동일한 토큰을 받아야 함 + assertThat(tokenIssueCount.get()).isEqualTo(threadCount); + // 실제 토큰 발급은 한 번만 일어나야 함 (동시성 제어) + verify(spotifyApi, atMostOnce()).clientCredentials(); + } + + @Test + @DisplayName("토큰 만료 여부 확인") + void isTokenExpired_shouldReturnCorrectStatus() { + // given - 만료된 토큰 + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", "expired-token"); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", System.currentTimeMillis() - 1000); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isTrue(); + + // given - 유효한 토큰 + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", System.currentTimeMillis() + 1000000); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isFalse(); + + // given - 토큰이 null + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", null); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isTrue(); + } + + @Test + @DisplayName("getAuthorizedApi는 유효한 토큰이 있는 API를 반환") + void getAuthorizedApi_shouldReturnApiWithValidToken() throws Exception { + // given + String expectedToken = "api-token"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + SpotifyApi api = spotifyClient.getAuthorizedApi(); + + // then + assertThat(api).isNotNull(); + assertThat(api).isEqualTo(spotifyApi); + verify(spotifyApi, times(1)).setAccessToken(expectedToken); + } +} +