diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java index f66ee2015..3bb6113f3 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java @@ -1,10 +1,23 @@ package konkuk.thip.book.adapter.in.web; +import konkuk.thip.book.adapter.in.web.response.GetBookSearchListResponse; +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import konkuk.thip.book.application.port.in.BookSearchUseCase; +import konkuk.thip.common.dto.BaseResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor public class BookQueryController { + private final BookSearchUseCase bookSearchUseCase; + + @GetMapping("/books") + public BaseResponse getBookSearchList(@RequestParam final String keyword, + @RequestParam final int page) { + NaverBookParseResult result = bookSearchUseCase.searchBooks(keyword, page); + return BaseResponse.ok(GetBookSearchListResponse.of(result, page)); + } + } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/request/DummyRequest.java b/src/main/java/konkuk/thip/book/adapter/in/web/request/DummyRequest.java deleted file mode 100644 index 6e72beef9..000000000 --- a/src/main/java/konkuk/thip/book/adapter/in/web/request/DummyRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.book.adapter.in.web.request; - -import lombok.Getter; - -@Getter -public class DummyRequest { -} diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookSearchListResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookSearchListResponse.java new file mode 100644 index 000000000..f67f07df6 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookSearchListResponse.java @@ -0,0 +1,57 @@ +package konkuk.thip.book.adapter.in.web.response; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import lombok.Builder; + +import java.util.List; + +import static konkuk.thip.book.adapter.out.api.NaverApiUtil.PAGE_SIZE; + +@Builder +public record GetBookSearchListResponse( + List searchResult, // 책 목록 + int page, // 현재 페이지 (1부터 시작) + int size, // 한 페이지에 포함되는 데이터 수 (페이지 크기) + long totalElements, // 전체 데이터 개수 + int totalPages, // 전체 페이지 수 + boolean last, // 마지막 페이지 여부 + boolean first // 첫 페이지 여부 +) { + public static GetBookSearchListResponse of(NaverBookParseResult result, int page) { + int totalElements = result.total(); + int totalPages = (int) Math.ceil((double) totalElements / PAGE_SIZE); + boolean last = (page >= totalPages); + boolean first = (page == 1); + + List bookDtos = result.naverBooks().stream() + .map(BookDto::of) + .toList(); + + return new GetBookSearchListResponse( + bookDtos, + page, + PAGE_SIZE, + totalElements, + totalPages, + last, + first + ); + } + public record BookDto( + String title, + String imageUrl, + String authorName, + String publisher, + String isbn + ) { + public static BookDto of(NaverBookParseResult.NaverBook naverBook) { + return new BookDto( + naverBook.title(), + naverBook.imageUrl(), + naverBook.author(), + naverBook.publisher(), + naverBook.isbn() + ); + } + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/BookApiAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/api/BookApiAdapter.java new file mode 100644 index 000000000..50f02c712 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/BookApiAdapter.java @@ -0,0 +1,19 @@ +package konkuk.thip.book.adapter.out.api; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import konkuk.thip.book.application.port.out.SearchBookQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BookApiAdapter implements SearchBookQueryPort { + + private final NaverApiUtil naverApiUtil; + + @Override + public NaverBookParseResult findBooksByKeyword(String keyword, int start) { + String xml = naverApiUtil.searchBook(keyword, start); // 네이버 API 호출 + return NaverBookXmlParser.parse(xml); // XML 파싱 + 페이징 정보 포함 + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java b/src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java new file mode 100644 index 000000000..13a340403 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/NaverApiUtil.java @@ -0,0 +1,108 @@ +package konkuk.thip.book.adapter.out.api; + +import konkuk.thip.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@RequiredArgsConstructor +@Component +public class NaverApiUtil { + + @Value("${naver.clientId}") + private String clientId; + @Value("${naver.clientSecret}") + private String clientSecret; + @Value("${naver.bookSearchUrl}") + private String bookSearchUrl; + + public static final int PAGE_SIZE = 10; + + public String searchBook(String keyword, int start){ + String query = keywordToEncoding(keyword); + String url = buildSearchApiUrl(query, start); + + Map requestHeaders = new HashMap<>(); + requestHeaders.put("X-Naver-Client-Id", clientId); + requestHeaders.put("X-Naver-Client-Secret", clientSecret); + + return get(url,requestHeaders); + } + + private String buildSearchApiUrl(String query,Integer start) { + return bookSearchUrl+query+"&display="+PAGE_SIZE+"&start="+start; + } + + private String keywordToEncoding(String keyword) { + String text = null; + try { + text = URLEncoder.encode(keyword, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new BusinessException(BOOK_KEYWORD_ENCODING_FAILED); + } + return text; + } + + + String get(String apiUrl, Map requestHeaders){ + HttpURLConnection con = connect(apiUrl); + try { + con.setRequestMethod("GET"); + for(Map.Entry header :requestHeaders.entrySet()) { + con.setRequestProperty(header.getKey(), header.getValue()); + } + + int responseCode = con.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출 + return readBody(con.getInputStream()); + } else { // 오류 발생 + return readBody(con.getErrorStream()); + } + } catch (IOException e) { + throw new BusinessException(BOOK_NAVER_API_REQUEST_ERROR); + } finally { + con.disconnect(); + } + } + + + private HttpURLConnection connect(String apiUrl){ + try { + URL url = new URL(apiUrl); + return (HttpURLConnection)url.openConnection(); + } catch (MalformedURLException e) { + throw new BusinessException(BOOK_NAVER_API_URL_ERROR); + } catch (IOException e) { + throw new BusinessException(BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED); + } + } + + + private String readBody(InputStream body){ + InputStreamReader streamReader = new InputStreamReader(body); + + try (BufferedReader lineReader = new BufferedReader(streamReader)) { + StringBuilder responseBody = new StringBuilder(); + + String line; + while ((line = lineReader.readLine()) != null) { + responseBody.append(line); + } + + return responseBody.toString(); + } catch (IOException e) { + throw new BusinessException(BOOK_NAVER_API_RESPONSE_ERROR); + } + } + +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java b/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java new file mode 100644 index 000000000..26e3c2828 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java @@ -0,0 +1,63 @@ +package konkuk.thip.book.adapter.out.api; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; +import org.w3c.dom.*; +import javax.xml.parsers.*; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import org.xml.sax.InputSource; + +public class NaverBookXmlParser { + + public static NaverBookParseResult parse(String xml) { + List Naverbooks = new ArrayList<>(); + int total = -1; + int start = -1; + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + InputSource is = new InputSource(new StringReader(xml)); + Document doc = builder.parse(is); + + NodeList channelNodes = doc.getElementsByTagName("channel"); + if (channelNodes.getLength() > 0) { + Element channel = (Element) channelNodes.item(0); + total = Integer.parseInt(getTagValue(channel, "total")); + start = Integer.parseInt(getTagValue(channel, "start")); + + NodeList itemNodes = channel.getElementsByTagName("item"); + for (int i = 0; i < itemNodes.getLength(); i++) { + Element item = (Element) itemNodes.item(i); + String title = getTagValue(item, "title"); + String imageUrl = getTagValue(item, "image"); + String author = getTagValue(item, "author"); + String publisher = getTagValue(item, "publisher"); + String isbn = getTagValue(item, "isbn"); + NaverBookParseResult.NaverBook naverBook = NaverBookParseResult.NaverBook.builder() + .title(title) + .imageUrl(imageUrl) + .author(author) + .publisher(publisher) + .isbn(isbn) + .build(); + Naverbooks.add(naverBook); + } + } + } catch (Exception e) { + throw new BusinessException(ErrorCode.BOOK_NAVER_API_PARSING_ERROR); + } + return NaverBookParseResult.of(Naverbooks, total, start); + } + + private static String getTagValue(Element element, String tag) { + NodeList nodeList = element.getElementsByTagName(tag); + if (nodeList.getLength() > 0 && nodeList.item(0).getFirstChild() != null) { + return nodeList.item(0).getFirstChild().getNodeValue(); + } + return ""; + } + +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverBookParseResult.java b/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverBookParseResult.java new file mode 100644 index 000000000..1ee7ec7af --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverBookParseResult.java @@ -0,0 +1,23 @@ +package konkuk.thip.book.adapter.out.api.dto; + +import lombok.Builder; + +import java.util.List; + + +public record NaverBookParseResult( + List naverBooks, + int total, + int start) { + @Builder + public record NaverBook( + String title, + String imageUrl, + String author, + String publisher, + String isbn + ) {} + public static NaverBookParseResult of(List books, int total, int start) { + return new NaverBookParseResult(books, total, start); + } +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookSearchUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookSearchUseCase.java new file mode 100644 index 000000000..3dd13ebf0 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/BookSearchUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.book.application.port.in; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; + +public interface BookSearchUseCase { + + NaverBookParseResult searchBooks(String keyword, int page); + +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/DummyUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/DummyUseCase.java deleted file mode 100644 index 4f29a36c7..000000000 --- a/src/main/java/konkuk/thip/book/application/port/in/DummyUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package konkuk.thip.book.application.port.in; - -public interface DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/book/application/port/out/SearchBookQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/SearchBookQueryPort.java new file mode 100644 index 000000000..739649bfd --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/out/SearchBookQueryPort.java @@ -0,0 +1,7 @@ +package konkuk.thip.book.application.port.out; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; + +public interface SearchBookQueryPort { + NaverBookParseResult findBooksByKeyword(String keyword, int start); +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java new file mode 100644 index 000000000..ae390c33c --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java @@ -0,0 +1,44 @@ +package konkuk.thip.book.application.service; + +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import konkuk.thip.book.application.port.in.BookSearchUseCase; +import konkuk.thip.book.application.port.out.SearchBookQueryPort; +import konkuk.thip.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static konkuk.thip.book.adapter.out.api.NaverApiUtil.PAGE_SIZE; +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@Service +@RequiredArgsConstructor +public class BookSearchService implements BookSearchUseCase { + + private final SearchBookQueryPort searchBookQueryPort; + + @Override + public NaverBookParseResult searchBooks(String keyword, int page) { + + if (keyword == null || keyword.isBlank()) { + throw new BusinessException(BOOK_KEYWORD_REQUIRED); + } + + if (page < 1) { + throw new BusinessException(BOOK_PAGE_NUMBER_INVALID); + } + + //유저의 최근검색어 로직 추가 + + int start = (page - 1) * PAGE_SIZE + 1; //검색 시작 위치 + NaverBookParseResult result = searchBookQueryPort.findBooksByKeyword(keyword, start); + + int totalElements = result.total(); + int totalPages = (totalElements + PAGE_SIZE - 1) / PAGE_SIZE; + if ( totalElements!=0 && page > totalPages) { + throw new BusinessException(BOOK_SEARCH_PAGE_OUT_OF_RANGE); + } + + return result; + } + +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/application/service/BookService.java b/src/main/java/konkuk/thip/book/application/service/BookService.java deleted file mode 100644 index d85dca239..000000000 --- a/src/main/java/konkuk/thip/book/application/service/BookService.java +++ /dev/null @@ -1,11 +0,0 @@ -package konkuk.thip.book.application.service; - -import konkuk.thip.book.application.port.in.DummyUseCase; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class BookService implements DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/common/dto/ErrorResponse.java b/src/main/java/konkuk/thip/common/dto/ErrorResponse.java index 40a253b5b..6b983a124 100644 --- a/src/main/java/konkuk/thip/common/dto/ErrorResponse.java +++ b/src/main/java/konkuk/thip/common/dto/ErrorResponse.java @@ -8,7 +8,7 @@ @JsonPropertyOrder({"success", "code", "message"}) public class ErrorResponse { - @JsonProperty("isSuccess:") + @JsonProperty("isSuccess") private final boolean success; private final int code; diff --git a/src/main/java/konkuk/thip/common/exception/BusinessException.java b/src/main/java/konkuk/thip/common/exception/BusinessException.java index e87c37d47..15b60ade5 100644 --- a/src/main/java/konkuk/thip/common/exception/BusinessException.java +++ b/src/main/java/konkuk/thip/common/exception/BusinessException.java @@ -8,11 +8,12 @@ public class BusinessException extends RuntimeException { private final ErrorCode errorCode; public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); this.errorCode = errorCode; } public BusinessException(ErrorCode errorCode, Exception e) { - super(e); + super(errorCode.getMessage(), e); this.errorCode = errorCode; } } diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 38fa09194..b538f025d 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -20,10 +20,25 @@ public enum ErrorCode implements ResponseCode { /** * 60000 : alias error */ - ALIAS_NOT_FOUND(HttpStatus.NOT_FOUND, 60001, "존재하지 않는 ALIAS 입니다."); + ALIAS_NOT_FOUND(HttpStatus.NOT_FOUND, 60001, "존재하지 않는 ALIAS 입니다."), + + + /** + * 80000 : book error + */ + BOOK_KEYWORD_ENCODING_FAILED(HttpStatus.BAD_REQUEST, 80000, "검색어 인코딩에 실패했습니다."), + BOOK_NAVER_API_REQUEST_ERROR(HttpStatus.BAD_REQUEST, 80001,"네이버 API 요청에 실패하였습니다."), + BOOK_NAVER_API_PARSING_ERROR(HttpStatus.BAD_REQUEST, 80002,"네이버 API 응답 파싱에 실패하였습니다."), + BOOK_NAVER_API_URL_ERROR(HttpStatus.BAD_REQUEST, 80003,"네이버 API URL이 잘못되었습니다."), + BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED(HttpStatus.BAD_REQUEST, 80004,"네이버 API 요청 중, HTTP 연결에 실패하였습니다."), + BOOK_NAVER_API_RESPONSE_ERROR(HttpStatus.BAD_REQUEST, 80005,"네이버 API 응답에 실패하였습니다."), + BOOK_SEARCH_PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST, 80006,"검색어 페이지가 범위를 벗어났습니다."), + BOOK_KEYWORD_REQUIRED(HttpStatus.BAD_REQUEST, 80007, "검색어는 필수 입력값입니다."), + BOOK_PAGE_NUMBER_INVALID(HttpStatus.BAD_REQUEST, 80008, "페이지 번호는 1 이상의 값이어야 합니다."); + + - ; private final HttpStatus httpStatus; private final int code; diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java new file mode 100644 index 000000000..20a1e4938 --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java @@ -0,0 +1,77 @@ +package konkuk.thip.book.adapter.in.web; + +import konkuk.thip.common.exception.code.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BookQueryControllerTest { + + @Autowired + private MockMvc mockMvc; + + + @Test + @DisplayName("책 검색 API 정상 호출 - 키워드와 페이지 번호가 주어졌을 때") + void searchBooks_success() throws Exception { + mockMvc.perform(get("/books") + .param("keyword", "테스트") + .param("page", "1") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.searchResult").isArray()) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.first").value(true)); + } + + @Test + @DisplayName("책 검색 API 실패 - 페이지가 범위를 벗어났을 때 400 에러 발생") + void searchBooks_pageOutOfRange() throws Exception { + mockMvc.perform(get("/books") + .param("keyword", "테스트") + .param("page", "99999") // totalPages보다 큰 값으로 가정 + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_SEARCH_PAGE_OUT_OF_RANGE.getCode())) + .andExpect(jsonPath("$.message", containsString("검색어 페이지가 범위를 벗어났습니다"))); + } + + + @Test + @DisplayName("책 검색 API 실패 - 키워드가 비어서 넘어올 때 400 에러 발생") + void searchBooks_keywordMissing_badRequest() throws Exception { + mockMvc.perform(get("/books") + .param("page", "1") + .param("keyword", "") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_KEYWORD_REQUIRED.getCode())) + .andExpect(jsonPath("$.message", containsString("검색어는 필수 입력값입니다"))); + } + + @Test + @DisplayName("책 검색 API 실패 - 페이지 번호가 1 미만일 때 400 에러 발생") + void searchBooks_pageInvalid_badRequest() throws Exception { + mockMvc.perform(get("/books") + .param("keyword", "테스트") + .param("page", "0") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_PAGE_NUMBER_INVALID.getCode())) + .andExpect(jsonPath("$.message", containsString("페이지 번호는 1 이상의 값이어야 합니다"))); + } +} diff --git a/src/test/java/konkuk/thip/book/adapter/out/api/NaverApiUtilTest.java b/src/test/java/konkuk/thip/book/adapter/out/api/NaverApiUtilTest.java new file mode 100644 index 000000000..b7ad7ae63 --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/out/api/NaverApiUtilTest.java @@ -0,0 +1,70 @@ +package konkuk.thip.book.adapter.out.api; + +import konkuk.thip.common.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NAVER_API_REQUEST_ERROR; +import static org.assertj.core.api.Assertions.*; + +class NaverApiUtilTest { + + private NaverApiUtil createTestUtil() { + NaverApiUtil util = Mockito.spy(new NaverApiUtil()); + // @Value로 주입되는 필드를 직접 세팅 + try { + Field clientIdField = NaverApiUtil.class.getDeclaredField("clientId"); + clientIdField.setAccessible(true); + clientIdField.set(util, "dummy-client-id"); + + Field clientSecretField = NaverApiUtil.class.getDeclaredField("clientSecret"); + clientSecretField.setAccessible(true); + clientSecretField.set(util, "dummy-client-secret"); + + Field bookSearchUrlField = NaverApiUtil.class.getDeclaredField("bookSearchUrl"); + bookSearchUrlField.setAccessible(true); + bookSearchUrlField.set(util, "https://dummy-url.com/search?query="); + } catch (Exception e) { + throw new RuntimeException(e); + } + return util; + } + + @Test + @DisplayName("NaverApiUtil - 정상적으로 XML 응답을 반환한다 (외부 API 성공 케이스)") + void searchBook_success_mocking() { + // given + NaverApiUtil naverApiUtil = createTestUtil(); + + String expectedXml = "11"; + Mockito.doReturn(expectedXml) + .when(naverApiUtil) + .get(Mockito.anyString(), Mockito.anyMap()); + + // when + String result = naverApiUtil.searchBook("테스트", 1); + + // then + assertThat(result).contains("1"); + assertThat(result).contains("1"); + } + + @Test + @DisplayName("NaverApiUtil - get 메서드에서 예외 발생 시 BusinessException 발생") + void searchBook_ioException() { + // given + NaverApiUtil naverApiUtil = createTestUtil(); + + Mockito.doThrow(new BusinessException(BOOK_NAVER_API_REQUEST_ERROR)) + .when(naverApiUtil) + .get(Mockito.anyString(), Mockito.anyMap()); + + // when & then + assertThatThrownBy(() -> naverApiUtil.searchBook("테스트", 1)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(BOOK_NAVER_API_REQUEST_ERROR.getMessage()); + } +}