diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index 021ffe12..e6e0eed3 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -50,6 +50,7 @@ public class ProductService { private static final int THREE = 3; private static final int TOP = 0; private static final int RANKING_SIZE = 3; + private static final int PAGE_SIZE = 10; private static final int DEFAULT_PAGE_SIZE = 10; private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; @@ -135,15 +136,23 @@ public RankingProductsResponse getTop3Products() { return RankingProductsResponse.toResponse(rankingProductDtos); } - public SearchProductsResponse searchProducts(final String query, final Pageable pageable) { - final Page products = productRepository.findAllByNameContaining(query, pageable); + public SearchProductsResponse searchProducts(final String query, final Long lastId) { + final List products = findAllByNameContaining(query, lastId); - final PageDto pageDto = PageDto.toDto(products); + final boolean hasNext = products.size() > PAGE_SIZE; final List productDtos = products.stream() .map(SearchProductDto::toDto) .collect(Collectors.toList()); - return SearchProductsResponse.toResponse(pageDto, productDtos); + return SearchProductsResponse.toResponse(hasNext, productDtos); + } + + private List findAllByNameContaining(final String query, final Long lastId) { + final PageRequest size = PageRequest.ofSize(PAGE_SIZE); + if (lastId == 0) { + return productRepository.findAllByNameContainingFirst(query, size); + } + return productRepository.findAllByNameContaining(query, lastId, size); } public SearchProductResultsResponse getSearchResults(final String query, final Pageable pageable) { diff --git a/backend/src/main/java/com/funeat/product/dto/SearchProductsResponse.java b/backend/src/main/java/com/funeat/product/dto/SearchProductsResponse.java index ccdeade5..098c8e50 100644 --- a/backend/src/main/java/com/funeat/product/dto/SearchProductsResponse.java +++ b/backend/src/main/java/com/funeat/product/dto/SearchProductsResponse.java @@ -1,24 +1,23 @@ package com.funeat.product.dto; -import com.funeat.common.dto.PageDto; import java.util.List; public class SearchProductsResponse { - private final PageDto page; + private final boolean hasNext; private final List products; - public SearchProductsResponse(final PageDto page, final List products) { - this.page = page; + private SearchProductsResponse(final boolean hasNext, final List products) { + this.hasNext = hasNext; this.products = products; } - public static SearchProductsResponse toResponse(final PageDto page, final List products) { - return new SearchProductsResponse(page, products); + public static SearchProductsResponse toResponse(final boolean hasNext, final List products) { + return new SearchProductsResponse(hasNext, products); } - public PageDto getPage() { - return page; + public boolean isHasNext() { + return hasNext; } public List getProducts() { diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index 27208f25..8d07bc49 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -26,7 +26,16 @@ List findAllByAverageRatingGreaterThan3(final LocalDateTi + "ORDER BY " + "(CASE WHEN p.name LIKE CONCAT(:name, '%') THEN 1 ELSE 2 END), " + "p.id DESC") - Page findAllByNameContaining(@Param("name") final String name, final Pageable pageable); + List findAllByNameContainingFirst(@Param("name") final String name, final Pageable pageable); + + @Query("SELECT p FROM Product p " + + "JOIN Product last ON last.id = :lastId " + + "WHERE p.name LIKE CONCAT('%', :name, '%') " + + "AND (last.name LIKE CONCAT(:name, '%') " + + "AND ((p.name LIKE CONCAT(:name, '%') AND p.id < :lastId) OR (p.name NOT LIKE CONCAT(:name, '%'))) " + + "OR (p.name NOT LIKE CONCAT(:name, '%') AND p.id < :lastId)) " + + "ORDER BY (CASE WHEN p.name LIKE CONCAT(:name, '%') THEN 1 ELSE 2 END), p.id DESC") + List findAllByNameContaining(@Param("name") final String name, final Long lastId, final Pageable pageable); @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) FROM Product p " + "LEFT JOIN Review r ON r.product.id = p.id " diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java index 46435aee..30f0701c 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -51,9 +51,8 @@ public ResponseEntity getRankingProducts() { @GetMapping("/search/products") public ResponseEntity searchProducts(@RequestParam final String query, - @PageableDefault final Pageable pageable) { - final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); - final SearchProductsResponse response = productService.searchProducts(query, pageRequest); + @RequestParam final Long lastId) { + final SearchProductsResponse response = productService.searchProducts(query, lastId); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductController.java b/backend/src/main/java/com/funeat/product/presentation/ProductController.java index 3fa704eb..2adff088 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -54,7 +54,7 @@ ResponseEntity getAllProductsInCategory( ) @GetMapping ResponseEntity searchProducts(@RequestParam final String query, - @PageableDefault final Pageable pageable); + @RequestParam final Long lastId); @Operation(summary = "상품 검색 결과 조회", description = "문자열을 받아 상품을 검색하고 검색 결과들을 조회한다.") @ApiResponse( diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java index 5a73e655..46fc1520 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -98,6 +98,7 @@ import com.funeat.product.dto.RankingProductDto; import com.funeat.product.dto.SearchProductDto; import com.funeat.product.dto.SearchProductResultDto; +import com.funeat.product.dto.SearchProductsResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.tag.dto.TagDto; import io.restassured.response.ExtractableResponse; @@ -414,14 +415,11 @@ class searchProducts_성공_테스트 { final var 상품1 = 단일_상품_저장(상품_애플망고_가격3000원_평점5점_생성(카테고리)); final var 상품2 = 단일_상품_저장(상품_망고빙수_가격5000원_평점4점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(2L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 상품_자동_완성_검색_요청("망고", FIRST_PAGE); + final var 응답 = 상품_자동_완성_검색_요청("망고", 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); 상품_자동_완성_검색_결과를_검증한다(응답, List.of(상품2, 상품1)); } @@ -432,14 +430,11 @@ class searchProducts_성공_테스트 { 단일_카테고리_저장(카테고리); 반복_애플망고_상품_저장(2, 카테고리); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(0L), 총_페이지(0L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 상품_자동_완성_검색_요청("김밥", FIRST_PAGE); + final var 응답 = 상품_자동_완성_검색_요청("김밥", 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); 상품_자동_완성_검색_결과를_검증한다(응답, Collections.emptyList()); } @@ -449,21 +444,18 @@ class searchProducts_성공_테스트 { final var 카테고리 = 카테고리_간편식사_생성(); 단일_카테고리_저장(카테고리); 단일_상품_저장(상품_망고빙수_가격5000원_평점4점_생성(카테고리)); - 반복_애플망고_상품_저장(10, 카테고리); - - final var 예상_응답_페이지1 = 응답_페이지_생성(총_데이터_개수(11L), 총_페이지(2L), 첫페이지O, 마지막페이지X, FIRST_PAGE, PAGE_SIZE); - final var 예상_응답_페이지2 = 응답_페이지_생성(총_데이터_개수(11L), 총_페이지(2L), 첫페이지X, 마지막페이지O, SECOND_PAGE, PAGE_SIZE); + 반복_애플망고_상품_저장(15, 카테고리); // when - final var 응답1 = 상품_자동_완성_검색_요청("망고", FIRST_PAGE); - final var 응답2 = 상품_자동_완성_검색_요청("망고", SECOND_PAGE); + final var 응답1 = 상품_자동_완성_검색_요청("망고", 0L); + + final var result = 응답1.as(SearchProductsResponse.class).getProducts(); + final var lastId = result.get(result.size() - 1).getId(); + final var 응답2 = 상품_자동_완성_검색_요청("망고", lastId); // then STATUS_CODE를_검증한다(응답1, 정상_처리); - 페이지를_검증한다(응답1, 예상_응답_페이지1); - STATUS_CODE를_검증한다(응답2, 정상_처리); - 페이지를_검증한다(응답2, 예상_응답_페이지2); 결과값이_이전_요청_결과값에_중복되는지_검증(응답1, 응답2); } @@ -477,14 +469,11 @@ class searchProducts_성공_테스트 { 반복_애플망고_상품_저장(9, 카테고리); 단일_상품_저장(상품_망고빙수_가격5000원_평점4점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(11L), 총_페이지(2L), 첫페이지O, 마지막페이지X, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 상품_자동_완성_검색_요청("망고", FIRST_PAGE); + final var 응답 = 상품_자동_완성_검색_요청("망고", 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); 상품_자동_완성_검색_결과를_검증한다(응답, List.of(상품11, 상품1, 상품10, 상품9, 상품8, 상품7, 상품6, 상품5, 상품4, 상품3)); } } diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java index d41bd5be..7de0073c 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -2,7 +2,6 @@ import static io.restassured.RestAssured.given; - import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -36,10 +35,10 @@ public class ProductSteps { .extract(); } - public static ExtractableResponse 상품_자동_완성_검색_요청(final String query, final Long page) { + public static ExtractableResponse 상품_자동_완성_검색_요청(final String query, final Long lastId) { return given() .queryParam("query", query) - .queryParam("page", page) + .queryParam("lastId", lastId) .when() .get("/api/search/products") .then() diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java index 10758547..a93c15f5 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -24,6 +24,7 @@ import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; @SuppressWarnings("NonAsciiCharacters") class ProductRepositoryTest extends RepositoryTest { @@ -102,7 +103,7 @@ class findAllByAverageRatingGreaterThan3_성공_테스트 { } @Nested - class findAllByNameContaining_성공_테스트 { + class findAllByNameContainingFirst_성공_테스트 { @Test void 상품명에_검색어가_포함된_상품들을_조회한다() { @@ -114,12 +115,36 @@ class findAllByNameContaining_성공_테스트 { final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); 복수_상품_저장(product1, product2); - final var page = 페이지요청_기본_생성(0, 10); + final var expected = List.of(product2, product1); + + // when + final var actual = productRepository.findAllByNameContainingFirst("망고", PageRequest.of(0, 2)); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findAllByNameContaining_성공_테스트 { + + @Test + void 상품명에_검색어가_포함된_상품들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_애플망고_가격3000원_평점5점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product4 = 상품_망고빙수_가격5000원_평점4점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); final var expected = List.of(product2, product1); // when - final var actual = productRepository.findAllByNameContaining("망고", page).getContent(); + final var actual = productRepository.findAllByNameContaining("망고", 3L, PageRequest.of(0, 4)); // then assertThat(actual).usingRecursiveComparison()