diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java index 4546ca1c5..7f2e2bc59 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -27,6 +27,8 @@ import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.recipe.dto.RecipeFavoriteRequest; +import com.funeat.recipe.dto.SearchRecipeResultDto; +import com.funeat.recipe.dto.SearchRecipeResultsResponse; import com.funeat.recipe.dto.SortingRecipesResponse; import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; import com.funeat.recipe.persistence.RecipeImageRepository; @@ -167,4 +169,18 @@ private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Re throw new MemberDuplicateFavoriteException(MEMBER_DUPLICATE_FAVORITE, member.getId()); } } + + public SearchRecipeResultsResponse getSearchResults(final String query, final Pageable pageable) { + final Page recipePages = recipeRepository.findAllByProductNameContaining(query, pageable); + + final PageDto page = PageDto.toDto(recipePages); + final List dtos = recipePages.stream() + .map(recipe -> { + final List findRecipeImages = recipeImageRepository.findByRecipe(recipe); + final List productsByRecipe = productRecipeRepository.findProductByRecipe(recipe); + return SearchRecipeResultDto.toDto(recipe, findRecipeImages, productsByRecipe); + }) + .collect(Collectors.toList()); + return SearchRecipeResultsResponse.toResponse(page, dtos); + } } diff --git a/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultDto.java b/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultDto.java new file mode 100644 index 000000000..0feb4b71d --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultDto.java @@ -0,0 +1,75 @@ +package com.funeat.recipe.dto; + +import com.funeat.product.domain.Product; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public class SearchRecipeResultDto { + + private final Long id; + private final String image; + private final String title; + private final RecipeAuthorDto author; + private final List products; + private final Long favoriteCount; + private final LocalDateTime createdAt; + + public SearchRecipeResultDto(final Long id, final String image, final String title, final RecipeAuthorDto author, + final List products, final Long favoriteCount, + final LocalDateTime createdAt) { + this.id = id; + this.image = image; + this.title = title; + this.author = author; + this.products = products; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + } + + public static SearchRecipeResultDto toDto(final Recipe recipe, final List images, + final List products) { + final List productRecipes = products.stream() + .map(ProductRecipeDto::toDto) + .collect(Collectors.toList()); + + if (images.isEmpty()) { + return new SearchRecipeResultDto(recipe.getId(), null, recipe.getTitle(), + RecipeAuthorDto.toDto(recipe.getMember()), productRecipes, recipe.getFavoriteCount(), + recipe.getCreatedAt()); + } + return new SearchRecipeResultDto(recipe.getId(), images.get(0).getImage(), recipe.getTitle(), + RecipeAuthorDto.toDto(recipe.getMember()), productRecipes, recipe.getFavoriteCount(), + recipe.getCreatedAt()); + } + + public Long getId() { + return id; + } + + public String getImage() { + return image; + } + + public String getTitle() { + return title; + } + + public RecipeAuthorDto getAuthor() { + return author; + } + + public List getProducts() { + return products; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultsResponse.java b/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultsResponse.java new file mode 100644 index 000000000..3fb88dc53 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/SearchRecipeResultsResponse.java @@ -0,0 +1,28 @@ +package com.funeat.recipe.dto; + +import com.funeat.common.dto.PageDto; +import java.util.List; + +public class SearchRecipeResultsResponse { + + private final PageDto page; + private final List recipes; + + public SearchRecipeResultsResponse(final PageDto page, final List recipes) { + this.page = page; + this.recipes = recipes; + } + + public static SearchRecipeResultsResponse toResponse(final PageDto page, + final List recipes) { + return new SearchRecipeResultsResponse(page, recipes); + } + + public PageDto getPage() { + return page; + } + + public List getRecipes() { + return recipes; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java index d2b67fb9d..58048a02d 100644 --- a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java +++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java @@ -10,11 +10,20 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface RecipeRepository extends JpaRepository { Page findRecipesByMember(final Member member, final Pageable pageable); + @Query("SELECT r FROM Recipe r " + + "LEFT JOIN ProductRecipe pr ON pr.recipe.id = r.id " + + "WHERE pr.product.name LIKE CONCAT('%', :name, '%') " + + "ORDER BY CASE " + + "WHEN pr.product.name LIKE CONCAT(:name, '%') THEN 1 " + + "ELSE 2 END") + Page findAllByProductNameContaining(@Param("name") final String name, final Pageable pageable); + Page findAll(final Pageable pageable); @Lock(PESSIMISTIC_WRITE) diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java index 8dcacf501..49728ce20 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java @@ -7,9 +7,11 @@ import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; import com.funeat.recipe.dto.SortingRecipesResponse; +import com.funeat.recipe.dto.SearchRecipeResultsResponse; import java.net.URI; import java.util.List; import javax.validation.Valid; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -65,4 +68,12 @@ public ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo return ResponseEntity.noContent().build(); } + + @GetMapping("/api/search/recipes/results") + public ResponseEntity getSearchResults(@RequestParam final String query, + @PageableDefault final Pageable pageable) { + final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + final SearchRecipeResultsResponse response = recipeService.getSearchResults(query, pageRequest); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java index 82573fc96..4b38ae763 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java @@ -5,6 +5,7 @@ import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; +import com.funeat.recipe.dto.SearchRecipeResultsResponse; import com.funeat.recipe.dto.SortingRecipesResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -60,4 +62,13 @@ ResponseEntity getRecipeDetail(@AuthenticationPrincipal fi ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long recipeId, @RequestBody RecipeFavoriteRequest request); + + @Operation(summary = "꿀조합 검색 결과 조회", description = "검색어가 포함된 상품이 있는 꿀조합 목록을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "꿀조합 검색 결과 조회 성공." + ) + @GetMapping + ResponseEntity getSearchResults(@RequestParam final String query, + @PageableDefault final Pageable pageable); } diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java index 97095f258..6bb067f83 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java @@ -8,6 +8,7 @@ import static com.funeat.acceptance.common.CommonSteps.정상_처리; import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_검색_결과_조회_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_목록_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_상세_정보_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_생성_요청; @@ -18,6 +19,7 @@ import static com.funeat.exception.CommonErrorCode.REQUEST_VALID_ERROR_CODE; import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; import static com.funeat.fixture.ProductFixture.레시피_안에_들어가는_상품_생성; @@ -26,6 +28,7 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.RecipeFixture.레시피_생성; import static com.funeat.fixture.RecipeFixture.레시피이미지_생성; import static com.funeat.fixture.RecipeFixture.레시피좋아요요청_생성; @@ -40,6 +43,7 @@ import com.funeat.product.domain.Product; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; +import com.funeat.recipe.dto.SearchRecipeResultDto; import com.funeat.recipe.dto.RecipeDto; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -55,6 +59,9 @@ @SuppressWarnings("NonAsciiCharacters") public class RecipeAcceptanceTest extends AcceptanceTest { + private static final Long PAGE_SIZE = 10L; + private static final Long FIRST_PAGE = 0L; + @Nested class writeRecipe_성공_테스트 { @@ -513,6 +520,80 @@ class likeRecipe_실패_테스트 { } } + @Nested + class getSearchResults_성공_테스트 { + + @Test + void 레시피_검색_결과들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_애플망고_가격3000원_평점5점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var productIds1 = 상품_아이디_변환(product1, product2); + final var productIds2 = 상품_아이디_변환(product1, product3); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + final var createRequest1 = 레시피추가요청_생성(productIds1); + final var createRequest2 = 레시피추가요청_생성(productIds2); + final var images = 여러_사진_요청(3); + final var recipeId1 = 레시피_추가_요청하고_id_반환(createRequest1, images, loginCookie); + final var recipeId2 = 레시피_추가_요청하고_id_반환(createRequest2, images, loginCookie); + + final var pageDto = new PageDto(2L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); + + final var expectedDto1 = 예상_레시피_검색_결과_변환(loginCookie, recipeId1); + final var expectedDto2 = 예상_레시피_검색_결과_변환(loginCookie, recipeId2); + final var expected = List.of(expectedDto1, expectedDto2); + + // when + final var response = 레시피_검색_결과_조회_요청("망고", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 레시피_검색_결과를_검증한다(response, expected); + } + + @Test + void 검색_결과에_레시피가_없으면_빈_리스트를_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_애플망고_가격3000원_평점5점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var productIds1 = 상품_아이디_변환(product1, product2); + final var productIds2 = 상품_아이디_변환(product1, product3); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + final var createRequest1 = 레시피추가요청_생성(productIds1); + final var createRequest2 = 레시피추가요청_생성(productIds2); + final var images = 여러_사진_요청(3); + + 레시피_생성_요청(createRequest1, images, loginCookie); + 레시피_생성_요청(createRequest2, images, loginCookie); + + final var pageDto = new PageDto(0L, 0L, true, true, FIRST_PAGE, PAGE_SIZE); + final var expected = Collections.emptyList(); + + // when + final var response = 레시피_검색_결과_조회_요청("참치", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 레시피_검색_결과를_검증한다(response, expected); + } + } + @Nested class getSortingRecipes_성공_테스트 { @@ -723,4 +804,18 @@ class getSortingRecipes_성공_테스트 { .map(Product::getId) .collect(Collectors.toList()); } + + private SearchRecipeResultDto 예상_레시피_검색_결과_변환(final String loginCookie, final Long recipeId1) { + final var response = 레시피_상세_정보_요청(loginCookie, recipeId1).as(RecipeDetailResponse.class); + return new SearchRecipeResultDto(response.getId(), response.getImages().get(0), response.getTitle(), + response.getAuthor(), response.getProducts(), response.getFavoriteCount(), response.getCreatedAt()); + } + + private void 레시피_검색_결과를_검증한다(final ExtractableResponse response, final List expected) { + final var actual = response.jsonPath() + .getList("recipes", SearchRecipeResultDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java index 74f8c69ab..9d87dbca7 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -82,4 +82,14 @@ public class RecipeSteps { .build()) .collect(Collectors.toList()); } + + public static ExtractableResponse 레시피_검색_결과_조회_요청(final String query, final int page) { + return given() + .queryParam("query", query) + .queryParam("page", page) + .when() + .get("/api/search/recipes/results") + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index ca9f7e694..7bd1e22bf 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -7,21 +7,67 @@ import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_내림차순_생성; import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_오름차순_생성; import static com.funeat.fixture.PageFixture.페이지요청_좋아요_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; import static com.funeat.fixture.ProductFixture.레시피_안에_들어가는_상품_생성; +import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.RecipeFixture.레시피_생성; import static com.funeat.fixture.RecipeFixture.레시피이미지_생성; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class RecipeRepositoryTest extends RepositoryTest { +@SuppressWarnings("NonAsciiCharacters") +public class RecipeRepositoryTest extends RepositoryTest { + + @Nested + class findAllByProductNameContaining_성공_테스트 { + + @Test + void 상품명에_검색어가_포함된_상품이_있는_레시피들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_애플망고_가격3000원_평점5점_생성(category); + 복수_상품_저장(product1, product2, product3); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var recipe1 = 레시피_생성(member); + final var recipe2 = 레시피_생성(member); + 복수_레시피_저장(recipe1, recipe2); + + final var productRecipe1 = 레시피_안에_들어가는_상품_생성(product1, recipe1); + final var productRecipe2 = 레시피_안에_들어가는_상품_생성(product2, recipe1); + final var productRecipe3 = 레시피_안에_들어가는_상품_생성(product3, recipe2); + 복수_레시피_상품_저장(productRecipe1, productRecipe2, productRecipe3); + + final var image1 = 레시피이미지_생성(recipe1); + final var image2 = 레시피이미지_생성(recipe2); + 복수_레시피_이미지_저장(image1, image2); + + final var page = 페이지요청_기본_생성(0, 10); + final var expected = List.of(recipe1, recipe2); + + // when + final var actual = recipeRepository.findAllByProductNameContaining("망고", page).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } @Nested class findAllRecipes_성공_테스트 {