From 0532bbe4466a23df72d5f078251bebd1574ec051 Mon Sep 17 00:00:00 2001
From: JFe <33208246+Go-Jaecheol@users.noreply.github.com>
Date: Wed, 18 Oct 2023 13:10:18 +0900
Subject: [PATCH] =?UTF-8?q?[BE]=20refactor:=20=EA=BF=80=EC=A1=B0=ED=95=A9?=
 =?UTF-8?q?=20=EB=9E=AD=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20?=
 =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#755)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: 꿀조합 랭킹 점수 계산 로직 추가

* refactor: 꿀조합 랭킹 기능 수정

* test: 꿀조합 랭킹 관련 테스트 추가

* refactor: import 정렬

* test: 상황에 따른 꿀조합 랭킹 서비스 테스트 추가

* refactor: Objects import 추가

* fix: 충돌 해결
---
 .../com/funeat/product/domain/Category.java   |   7 +-
 .../recipe/application/RecipeService.java     |   9 +-
 .../java/com/funeat/recipe/domain/Recipe.java |  18 ++
 .../recipe/persistence/RecipeRepository.java  |   4 +-
 .../presentation/ReviewApiController.java     |   4 -
 .../com/funeat/fixture/RecipeFixture.java     |   6 +
 .../recipe/application/RecipeServiceTest.java | 156 ++++++++++++++++++
 .../com/funeat/recipe/domain/RecipeTest.java  |  36 ++++
 .../persistence/RecipeRepositoryTest.java     |  38 ++++-
 9 files changed, 259 insertions(+), 19 deletions(-)
 create mode 100644 backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java

diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java
index 5d6c62a08..7702a087e 100644
--- a/backend/src/main/java/com/funeat/product/domain/Category.java
+++ b/backend/src/main/java/com/funeat/product/domain/Category.java
@@ -1,6 +1,11 @@
 package com.funeat.product.domain;
 
-import javax.persistence.*;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
 
 @Entity
 public class Category {
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 d9ede67c3..19545b20b 100644
--- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java
+++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java
@@ -44,6 +44,7 @@
 import com.funeat.recipe.persistence.RecipeImageRepository;
 import com.funeat.recipe.persistence.RecipeRepository;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -61,8 +62,8 @@
 @Transactional(readOnly = true)
 public class RecipeService {
 
-    private static final int THREE = 3;
-    private static final int TOP = 0;
+    private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L;
+    private static final int RANKING_SIZE = 3;
     private static final int RECIPE_COMMENT_PAGE_SIZE = 10;
     private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11;
 
@@ -206,9 +207,11 @@ public SearchRecipeResultsResponse getSearchResults(final String query, final Pa
     }
 
     public RankingRecipesResponse getTop3Recipes() {
-        final List<Recipe> recipes = recipeRepository.findRecipesByOrderByFavoriteCountDesc(PageRequest.of(TOP, THREE));
+        final List<Recipe> recipes = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT);
 
         final List<RankingRecipeDto> dtos = recipes.stream()
+                .sorted(Comparator.comparing(Recipe::calculateRankingScore).reversed())
+                .limit(RANKING_SIZE)
                 .map(recipe -> {
                     final List<RecipeImage> findRecipeImages = recipeImageRepository.findByRecipe(recipe);
                     final RecipeAuthorDto author = RecipeAuthorDto.toDto(recipe.getMember());
diff --git a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java
index dcb607148..5ffb0438b 100644
--- a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java
+++ b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java
@@ -2,6 +2,7 @@
 
 import com.funeat.member.domain.Member;
 import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -14,6 +15,8 @@
 @Entity
 public class Recipe {
 
+    private static final double RANKING_GRAVITY = 0.1;
+
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Long id;
@@ -48,6 +51,21 @@ public Recipe(final String title, final String content, final Member member,
         this.favoriteCount = favoriteCount;
     }
 
+    public Recipe(final String title, final String content, final Member member, final Long favoriteCount,
+                  final LocalDateTime createdAt) {
+        this.title = title;
+        this.content = content;
+        this.member = member;
+        this.favoriteCount = favoriteCount;
+        this.createdAt = createdAt;
+    }
+
+    public Double calculateRankingScore() {
+        final long age = ChronoUnit.DAYS.between(createdAt, LocalDateTime.now());
+        final double denominator = Math.pow(age + 1.0, RANKING_GRAVITY);
+        return favoriteCount / denominator;
+    }
+
     public void addFavoriteCount() {
         this.favoriteCount++;
     }
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 4d1a3a306..ce5ef3c31 100644
--- a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java
+++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java
@@ -32,9 +32,9 @@ public interface RecipeRepository extends JpaRepository<Recipe, Long> {
     @Query("SELECT r FROM Recipe r LEFT JOIN ProductRecipe pr ON pr.product = :product WHERE pr.recipe.id = r.id")
     Page<Recipe> findRecipesByProduct(final Product product, final Pageable pageable);
 
-    List<Recipe> findRecipesByOrderByFavoriteCountDesc(final Pageable pageable);
-
     @Lock(PESSIMISTIC_WRITE)
     @Query("SELECT r FROM Recipe r WHERE r.id=:id")
     Optional<Recipe> findByIdForUpdate(final Long id);
+
+    List<Recipe> findRecipesByFavoriteCountGreaterThanEqual(final Long favoriteCount);
 }
diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java
index cb68d6e6b..0249b6967 100644
--- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java
+++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java
@@ -11,14 +11,10 @@
 import com.funeat.review.dto.SortingReviewRequest;
 import com.funeat.review.dto.SortingReviewsResponse;
 import java.net.URI;
-import java.util.Objects;
 import java.util.Optional;
 import javax.validation.Valid;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.web.PageableDefault;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.ModelAttribute;
 import org.springframework.web.bind.annotation.PatchMapping;
diff --git a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java
index 2050727f3..2d3bb3deb 100644
--- a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java
+++ b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java
@@ -6,6 +6,7 @@
 import com.funeat.recipe.domain.RecipeImage;
 import com.funeat.recipe.dto.RecipeCreateRequest;
 import com.funeat.recipe.dto.RecipeFavoriteRequest;
+import java.time.LocalDateTime;
 import java.util.List;
 
 @SuppressWarnings("NonAsciiCharacters")
@@ -33,6 +34,11 @@ public class RecipeFixture {
         return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!", member, favoriteCount);
     }
 
+    public static Recipe 레시피_생성(final Member member, final Long favoriteCount, final LocalDateTime createdAt) {
+        return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!",
+                member, favoriteCount, createdAt);
+    }
+
     public static RecipeFavorite 레시피_좋아요_생성(final Member member, final Recipe recipe, final Boolean favorite) {
         return new RecipeFavorite(member, recipe, favorite);
     }
diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java
index c0f68e789..81badf4f6 100644
--- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java
+++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java
@@ -36,6 +36,9 @@
 import com.funeat.product.domain.CategoryType;
 import com.funeat.product.domain.Product;
 import com.funeat.product.exception.ProductException.ProductNotFoundException;
+import com.funeat.recipe.dto.RankingRecipeDto;
+import com.funeat.recipe.dto.RankingRecipesResponse;
+import com.funeat.recipe.dto.RecipeAuthorDto;
 import com.funeat.recipe.dto.RecipeCommentCondition;
 import com.funeat.recipe.dto.RecipeCommentCreateRequest;
 import com.funeat.recipe.dto.RecipeCommentResponse;
@@ -43,6 +46,7 @@
 import com.funeat.recipe.dto.RecipeDetailResponse;
 import com.funeat.recipe.dto.RecipeDto;
 import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException;
+import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -551,6 +555,158 @@ class likeRecipe_실패_테스트 {
         }
     }
 
+    @Nested
+    class getTop3Recipes_성공_테스트 {
+
+        @Nested
+        class 꿀조합_개수에_대한_테스트 {
+
+            @Test
+            void 전체_꿀조합이_하나도_없어도_반환값은_있어야한다() {
+                // given
+                final var expected = RankingRecipesResponse.toResponse(Collections.emptyList());
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+
+            @Test
+            void 랭킹_조건에_부합하는_꿀조합이_1개면_꿀조합이_1개_반환된다() {
+                // given
+                final var member = 멤버_멤버1_생성();
+                단일_멤버_저장(member);
+
+                final var now = LocalDateTime.now();
+                final var recipe = 레시피_생성(member, 2L, now);
+                단일_꿀조합_저장(recipe);
+
+                final var author = RecipeAuthorDto.toDto(member);
+                final var rankingRecipeDto = RankingRecipeDto.toDto(recipe, Collections.emptyList(), author);
+                final var rankingRecipesDtos = Collections.singletonList(rankingRecipeDto);
+                final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+
+            @Test
+            void 랭킹_조건에_부합하는_꿀조합이_2개면_꿀조합이_2개_반환된다() {
+                // given
+                final var member = 멤버_멤버1_생성();
+                단일_멤버_저장(member);
+
+                final var now = LocalDateTime.now();
+                final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L));
+                final var recipe2 = 레시피_생성(member, 2L, now);
+                복수_꿀조합_저장(recipe1, recipe2);
+
+                final var author = RecipeAuthorDto.toDto(member);
+                final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
+                final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
+                final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
+                final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+
+            @Test
+            void 전체_꿀조합_중_랭킹이_높은_상위_3개_꿀조합을_구할_수_있다() {
+                // given
+                final var member = 멤버_멤버1_생성();
+                단일_멤버_저장(member);
+
+                final var now = LocalDateTime.now();
+                final var recipe1 = 레시피_생성(member, 4L, now.minusDays(10L));
+                final var recipe2 = 레시피_생성(member, 6L, now.minusDays(10L));
+                final var recipe3 = 레시피_생성(member, 5L, now);
+                final var recipe4 = 레시피_생성(member, 6L, now);
+                복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4);
+
+                final var author = RecipeAuthorDto.toDto(member);
+                final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
+                final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
+                final var rankingRecipeDto3 = RankingRecipeDto.toDto(recipe3, Collections.emptyList(), author);
+                final var rankingRecipeDto4 = RankingRecipeDto.toDto(recipe4, Collections.emptyList(), author);
+                final var rankingRecipesDtos = List.of(rankingRecipeDto4, rankingRecipeDto3, rankingRecipeDto2);
+                final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+        }
+
+        @Nested
+        class 꿀조합_랭킹_점수에_대한_테스트 {
+
+            @Test
+            void 꿀조합_좋아요_수가_같으면_최근_생성된_꿀조합의_랭킹을_더_높게_반환한다() {
+                // given
+                final var member = 멤버_멤버1_생성();
+                단일_멤버_저장(member);
+
+                final var now = LocalDateTime.now();
+                final var recipe1 = 레시피_생성(member, 10L, now.minusDays(9L));
+                final var recipe2 = 레시피_생성(member, 10L, now.minusDays(4L));
+                복수_꿀조합_저장(recipe1, recipe2);
+
+                final var author = RecipeAuthorDto.toDto(member);
+                final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
+                final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
+                final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
+                final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+
+            @Test
+            void 꿀조합_생성_일자가_같으면_좋아요_수가_많은_꿀조합의_랭킹을_더_높게_반환한다() {
+                // given
+                final var member = 멤버_멤버1_생성();
+                단일_멤버_저장(member);
+
+                final var now = LocalDateTime.now();
+                final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L));
+                final var recipe2 = 레시피_생성(member, 4L, now.minusDays(1L));
+                복수_꿀조합_저장(recipe1, recipe2);
+
+                final var author = RecipeAuthorDto.toDto(member);
+                final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
+                final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
+                final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
+                final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);
+
+                // when
+                final var actual = recipeService.getTop3Recipes();
+
+                // then
+                assertThat(actual).usingRecursiveComparison()
+                        .isEqualTo(expected);
+            }
+        }
+    }
+  
     @Nested
     class writeCommentOfRecipe_성공_테스트 {
 
diff --git a/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java
new file mode 100644
index 000000000..7a0d28030
--- /dev/null
+++ b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java
@@ -0,0 +1,36 @@
+package com.funeat.recipe.domain;
+
+import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
+import static com.funeat.fixture.RecipeFixture.레시피_생성;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDateTime;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("NonAsciiCharacters")
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
+class RecipeTest {
+
+    @Nested
+    class calculateRankingScore_성공_테스트 {
+
+        @Test
+        void 꿀조합_좋아요_수와_꿀조합_생성_시간으로_해당_꿀조합의_랭킹_점수를_구할_수_있다() {
+            // given
+            final var member = 멤버_멤버1_생성();
+            final var favoriteCount = 4L;
+            final var recipe = 레시피_생성(member, favoriteCount, LocalDateTime.now().minusDays(1L));
+
+            final var expected = favoriteCount / Math.pow(2.0, 0.1);
+
+            // when
+            final var actual = recipe.calculateRankingScore();
+
+            // then
+            assertThat(actual).isEqualTo(expected);
+        }
+    }
+}
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 494f7b215..9c53177db 100644
--- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java
+++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java
@@ -21,6 +21,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 
 import com.funeat.common.RepositoryTest;
+import java.util.Collections;
 import java.util.List;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -259,25 +260,44 @@ class findRecipesByProduct_성공_테스트 {
     }
 
     @Nested
-    class findRecipesByOrderByFavoriteCountDesc_성공_테스트 {
+    class findRecipesByFavoriteCountGreaterThanEqual_성공_테스트 {
 
         @Test
-        void 좋아요순으로_상위_3개의_레시피들을_조회한다() {
+        void 특정_좋아요_수_이상인_모든_꿀조합들을_조회한다() {
             // given
             final var member = 멤버_멤버1_생성();
             단일_멤버_저장(member);
 
-            final var recipe1 = 레시피_생성(member, 1L);
-            final var recipe2 = 레시피_생성(member, 2L);
-            final var recipe3 = 레시피_생성(member, 3L);
-            final var recipe4 = 레시피_생성(member, 4L);
+            final var recipe1 = 레시피_생성(member, 0L);
+            final var recipe2 = 레시피_생성(member, 1L);
+            final var recipe3 = 레시피_생성(member, 10L);
+            final var recipe4 = 레시피_생성(member, 100L);
             복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4);
 
-            final var page = 페이지요청_기본_생성(0, 3);
-            final var expected = List.of(recipe4, recipe3, recipe2);
+            final var expected = List.of(recipe2, recipe3, recipe4);
+
+            // when
+            final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L);
+
+            // then
+            assertThat(actual).usingRecursiveComparison()
+                    .isEqualTo(expected);
+        }
+
+        @Test
+        void 특정_좋아요_수_이상인_꿀조합이_없으면_빈_리스트를_반환한다() {
+            // given
+            final var member = 멤버_멤버1_생성();
+            단일_멤버_저장(member);
+
+            final var recipe1 = 레시피_생성(member, 0L);
+            final var recipe2 = 레시피_생성(member, 0L);
+            복수_꿀조합_저장(recipe1, recipe2);
+
+            final var expected = Collections.emptyList();
 
             // when
-            final var actual = recipeRepository.findRecipesByOrderByFavoriteCountDesc(page);
+            final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L);
 
             // then
             assertThat(actual).usingRecursiveComparison()