diff --git a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java index 73169e1..7dcfea1 100644 --- a/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java +++ b/src/main/java/com/hlab/yanalyst/service/YoutubeCommentService.java @@ -1,12 +1,13 @@ package com.hlab.yanalyst.service; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import com.hlab.yanalyst.web.dto.CommentCardDto; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -30,6 +31,8 @@ public class YoutubeCommentService { /** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */ private static final int MAX_PAGES = 5; + private static final ObjectMapper MAPPER = new ObjectMapper(); + public List fetchComments(String urlOrId) { String videoId = YoutubeVideoIdParser.parse(urlOrId); if (videoId == null) { @@ -56,11 +59,10 @@ public class YoutubeCommentService { JsonNode root; try { root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class); - } catch (HttpClientErrorException.Forbidden e) { - if (result.isEmpty()) { - throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다."); - } - break; + } catch (RestClientResponseException e) { + // 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시) + if (!result.isEmpty()) break; + throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString()))); } if (root == null || !root.has("items")) break; @@ -84,4 +86,30 @@ public class YoutubeCommentService { return result; } + + /** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */ + private static String extractReason(String body) { + if (body == null || body.isBlank()) return null; + try { + JsonNode errs = MAPPER.readTree(body).path("error").path("errors"); + if (errs.isArray() && errs.size() > 0) { + String reason = errs.get(0).path("reason").asText(null); + if (reason != null && !reason.isBlank()) return reason; + } + } catch (Exception ignore) { + // 본문이 JSON이 아니면 reason 없음 + } + return null; + } + + /** YouTube 오류 reason → 사용자용 안내 메시지. */ + private static String messageFor(String reason) { + if ("commentsDisabled".equals(reason)) { + return "이 영상은 댓글이 비활성화되어 있습니다."; + } + if ("quotaExceeded".equals(reason) || "dailyLimitExceeded".equals(reason) || "rateLimitExceeded".equals(reason)) { + return "YouTube API 일일 쿼터를 초과했습니다. 잠시 후 다시 시도해주세요."; + } + return "YouTube API 호출에 실패했습니다" + (reason != null ? " (" + reason + ")" : "") + "."; + } } diff --git a/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java b/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java new file mode 100644 index 0000000..ff41e2a --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/service/YoutubeCommentServiceTest.java @@ -0,0 +1,69 @@ +package com.hlab.yanalyst.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class YoutubeCommentServiceTest { + + private YoutubeCommentService newService(RestTemplate rt) { + YoutubeQuotaGuard guard = mock(YoutubeQuotaGuard.class); + when(guard.tryConsume(anyLong())).thenReturn(true); + YoutubeCommentService svc = new YoutubeCommentService(rt, guard); + ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY"); + return svc; + } + + private HttpClientErrorException apiError(HttpStatus status, String reason) { + String body = "{\"error\":{\"code\":" + status.value() + + ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}"; + return HttpClientErrorException.create(status, status.getReasonPhrase(), + HttpHeaders.EMPTY, body.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + } + + @Test + void commentsDisabled_throwsDisabledMessage() { + RestTemplate rt = mock(RestTemplate.class); + when(rt.getForObject(any(URI.class), eq(JsonNode.class))) + .thenThrow(apiError(HttpStatus.FORBIDDEN, "commentsDisabled")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> newService(rt).fetchComments("dQw4w9WgXcQ")); + assertTrue(ex.getMessage().contains("비활성화"), ex.getMessage()); + } + + @Test + void quotaExceeded_throwsQuotaMessage_notDisabled() { + RestTemplate rt = mock(RestTemplate.class); + when(rt.getForObject(any(URI.class), eq(JsonNode.class))) + .thenThrow(apiError(HttpStatus.FORBIDDEN, "quotaExceeded")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> newService(rt).fetchComments("dQw4w9WgXcQ")); + assertTrue(ex.getMessage().contains("쿼터"), ex.getMessage()); + assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage()); + } + + @Test + void invalidKey_throwsMeaningfulMessage_notDisabled() { + RestTemplate rt = mock(RestTemplate.class); + when(rt.getForObject(any(URI.class), eq(JsonNode.class))) + .thenThrow(apiError(HttpStatus.BAD_REQUEST, "keyInvalid")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> newService(rt).fetchComments("dQw4w9WgXcQ")); + assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage()); + assertTrue(ex.getMessage().contains("keyInvalid"), ex.getMessage()); + } +}