Skip to content

Commit

Permalink
[BE] feat: 꿀조합 검색 기능 구현 (#477)
Browse files Browse the repository at this point in the history
* feat: 꿀조합 검색 기능 controller 및 dto 추가

* feat: 검색어가 포함된 상품이 있는 꿀조합 목록 조회하는 RecipeRepository 메서드 추가

* feat: 꿀조합 검색 기능 구현

* test: 꿀조합 검색 관련 repository 테스트 추가

* test: 꿀조합 검색 관련 인수 테스트 추가
  • Loading branch information
Go-Jaecheol authored Aug 17, 2023
1 parent d50f559 commit 1f43c9e
Show file tree
Hide file tree
Showing 9 changed files with 304 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Recipe> recipePages = recipeRepository.findAllByProductNameContaining(query, pageable);

final PageDto page = PageDto.toDto(recipePages);
final List<SearchRecipeResultDto> dtos = recipePages.stream()
.map(recipe -> {
final List<RecipeImage> findRecipeImages = recipeImageRepository.findByRecipe(recipe);
final List<Product> productsByRecipe = productRecipeRepository.findProductByRecipe(recipe);
return SearchRecipeResultDto.toDto(recipe, findRecipeImages, productsByRecipe);
})
.collect(Collectors.toList());
return SearchRecipeResultsResponse.toResponse(page, dtos);
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductRecipeDto> 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<ProductRecipeDto> 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<RecipeImage> images,
final List<Product> products) {
final List<ProductRecipeDto> 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<ProductRecipeDto> getProducts() {
return products;
}

public Long getFavoriteCount() {
return favoriteCount;
}

public LocalDateTime getCreatedAt() {
return createdAt;
}
}
Original file line number Diff line number Diff line change
@@ -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<SearchRecipeResultDto> recipes;

public SearchRecipeResultsResponse(final PageDto page, final List<SearchRecipeResultDto> recipes) {
this.page = page;
this.recipes = recipes;
}

public static SearchRecipeResultsResponse toResponse(final PageDto page,
final List<SearchRecipeResultDto> recipes) {
return new SearchRecipeResultsResponse(page, recipes);
}

public PageDto getPage() {
return page;
}

public List<SearchRecipeResultDto> getRecipes() {
return recipes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Recipe, Long> {

Page<Recipe> 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<Recipe> findAllByProductNameContaining(@Param("name") final String name, final Pageable pageable);

Page<Recipe> findAll(final Pageable pageable);

@Lock(PESSIMISTIC_WRITE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -65,4 +68,12 @@ public ResponseEntity<Void> likeRecipe(@AuthenticationPrincipal final LoginInfo

return ResponseEntity.noContent().build();
}

@GetMapping("/api/search/recipes/results")
public ResponseEntity<SearchRecipeResultsResponse> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -60,4 +62,13 @@ ResponseEntity<RecipeDetailResponse> getRecipeDetail(@AuthenticationPrincipal fi
ResponseEntity<Void> likeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo,
@PathVariable final Long recipeId,
@RequestBody RecipeFavoriteRequest request);

@Operation(summary = "꿀조합 검색 결과 조회", description = "검색어가 포함된 상품이 있는 꿀조합 목록을 조회한다.")
@ApiResponse(
responseCode = "200",
description = "꿀조합 검색 결과 조회 성공."
)
@GetMapping
ResponseEntity<SearchRecipeResultsResponse> getSearchResults(@RequestParam final String query,
@PageableDefault final Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.레시피_생성_요청;
Expand All @@ -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.레시피_안에_들어_상품_생성;
Expand All @@ -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.레시피좋아요요청_생성;
Expand All @@ -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;
Expand All @@ -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_성공_테스트 {

Expand Down Expand Up @@ -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_성공_테스트 {

Expand Down Expand Up @@ -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 <T> void 레시피_검색_결과를_검증한다(final ExtractableResponse<Response> response, final List<T> expected) {
final var actual = response.jsonPath()
.getList("recipes", SearchRecipeResultDto.class);

assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,14 @@ public class RecipeSteps {
.build())
.collect(Collectors.toList());
}

public static ExtractableResponse<Response> 레시피_검색_결과_조회_요청(final String query, final int page) {
return given()
.queryParam("query", query)
.queryParam("page", page)
.when()
.get("/api/search/recipes/results")
.then()
.extract();
}
}
Loading

0 comments on commit 1f43c9e

Please sign in to comment.