From 215003f9a28f33ce48c3c0f6ff9342a0a59578e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Mon, 16 Oct 2023 12:46:55 +0900 Subject: [PATCH 01/20] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 마이페이지 리뷰 디자인 수정 * refactor: border bottom 무조건 있도록 수정 * refactor: bookmark 로직 삭제 * refactor: 로딩 rotate 방향 수정 * feat: link 아이콘 추가 * feat: 리뷰 상세 페이지 구현 * refactor: categotyType string을 CategoryVaraint type으로 변경 * chore: yarn 재설치 * refactor: 필요없는 css 값 삭제 * refactor: params review id 받도록 수정 * refactor: query key 추가 --- frontend/.storybook/preview-body.html | 9 ++ frontend/package.json | 3 +- frontend/src/apis/index.ts | 1 + .../src/components/Common/Loading/Loading.tsx | 2 +- .../SectionTitle/SectionTitle.stories.tsx | 2 - .../Common/SectionTitle/SectionTitle.tsx | 31 ++-- .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 6 + .../MemberReviewList/MemberReviewList.tsx | 8 +- .../ReviewRankingItem/ReviewRankingItem.tsx | 12 +- .../ReviewRankingList/ReviewRankingList.tsx | 2 +- frontend/src/constants/path.ts | 1 + frontend/src/hooks/queries/review/index.ts | 1 + .../queries/review/useReviewDetailQuery.ts | 16 +++ frontend/src/mocks/data/productDetail.json | 1 - frontend/src/mocks/data/productDetails.json | 2 - frontend/src/mocks/data/reviewDetail.json | 29 ++++ frontend/src/mocks/handlers/reviewHandlers.ts | 5 + frontend/src/pages/ProductDetailPage.tsx | 4 +- frontend/src/pages/ReviewDetailPage.tsx | 132 ++++++++++++++++++ frontend/src/router/index.tsx | 9 ++ frontend/src/types/product.ts | 1 - frontend/src/types/ranking.ts | 3 +- frontend/src/types/response.ts | 6 +- frontend/src/types/review.ts | 8 +- frontend/src/types/search.ts | 3 +- frontend/yarn.lock | 20 ++- 27 files changed, 272 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/queries/review/useReviewDetailQuery.ts create mode 100644 frontend/src/mocks/data/reviewDetail.json create mode 100644 frontend/src/pages/ReviewDetailPage.tsx diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 4e44004eb..9e1c31c57 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -100,6 +100,15 @@ d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z" /> + + + + + ; export const Default: Story = { args: { name: '사이다', - bookmark: false, }, }; export const Bookmarked: Story = { args: { name: '사이다', - bookmark: true, }, }; diff --git a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx index 106c60759..c9d649ddb 100644 --- a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx +++ b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx @@ -1,4 +1,5 @@ -import { Button, Heading, theme } from '@fun-eat/design-system'; +import { Button, Heading, Link, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; @@ -6,10 +7,10 @@ import { useRoutePage } from '@/hooks/common'; interface SectionTitleProps { name: string; - bookmark?: boolean; + link?: string; } -const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { +const SectionTitle = ({ name, link }: SectionTitleProps) => { const { routeBack } = useRoutePage(); return ( @@ -18,18 +19,15 @@ const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { - - {name} - + {link ? ( + + {name} + + ) : ( + {name} + )} + {link && } - {bookmark && ( - - )} ); }; @@ -45,9 +43,12 @@ const SectionTitleContainer = styled.div` const SectionTitleWrapper = styled.div` display: flex; align-items: center; - column-gap: 16px; svg { padding-top: 2px; } `; + +const ProductName = styled(Heading)` + margin: 0 5px 0 16px; +`; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index d8d11d326..b8be9a974 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [ 'plus', 'pencil', 'camera', + 'link', 'plane', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index b4811ca73..65cf49d3d 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -74,6 +74,12 @@ const SvgSprite = () => { + + + + + + diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index 50398fedf..fde211413 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -48,12 +48,8 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { {reviewsToDisplay.map((reviewRanking) => (
  • - - + +
  • ))} diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index 70fd89c32..c2004c20e 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -7,13 +7,14 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; + isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -41,13 +42,14 @@ const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div` +const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: 1px solid ${({ theme }) => theme.borderColors.disabled}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; + border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx index 7b6c8272c..99f10d5f3 100644 --- a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -28,7 +28,7 @@ const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => {
  • diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 6fd8735c8..f729a74b2 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -4,5 +4,6 @@ export const PATH = { PRODUCT_LIST: '/products', MEMBER: '/members', RECIPE: '/recipes', + REVIEW: '/reviews', LOGIN: '/login', } as const; diff --git a/frontend/src/hooks/queries/review/index.ts b/frontend/src/hooks/queries/review/index.ts index e8bb44d4a..78fd628d2 100644 --- a/frontend/src/hooks/queries/review/index.ts +++ b/frontend/src/hooks/queries/review/index.ts @@ -1,3 +1,4 @@ export { default as useReviewTagsQuery } from './useReviewTagsQuery'; export { default as useReviewFavoriteMutation } from './useReviewFavoriteMutation'; export { default as useReviewRegisterFormMutation } from './useReviewRegisterFormMutation'; +export { default as useReviewDetailQuery } from './useReviewDetailQuery'; diff --git a/frontend/src/hooks/queries/review/useReviewDetailQuery.ts b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts new file mode 100644 index 000000000..a80914053 --- /dev/null +++ b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts @@ -0,0 +1,16 @@ +import { useSuspendedQuery } from '../useSuspendedQuery'; + +import { reviewApi } from '@/apis'; +import type { ReviewDetailResponse } from '@/types/response'; + +const fetchReviewDetail = async (reviewId: number) => { + const response = await reviewApi.get({ params: `/${reviewId}` }); + const data: ReviewDetailResponse = await response.json(); + return data; +}; + +const useReviewDetailQuery = (reviewId: number) => { + return useSuspendedQuery(['review', reviewId, 'detail'], () => fetchReviewDetail(reviewId)); +}; + +export default useReviewDetailQuery; diff --git a/frontend/src/mocks/data/productDetail.json b/frontend/src/mocks/data/productDetail.json index 386c7ae71..2695b51b1 100644 --- a/frontend/src/mocks/data/productDetail.json +++ b/frontend/src/mocks/data/productDetail.json @@ -5,7 +5,6 @@ "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { diff --git a/frontend/src/mocks/data/productDetails.json b/frontend/src/mocks/data/productDetails.json index e3c68dab0..c386ce680 100644 --- a/frontend/src/mocks/data/productDetails.json +++ b/frontend/src/mocks/data/productDetails.json @@ -6,7 +6,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { @@ -33,7 +32,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.0, - "bookmark": true, "reviewCount": 55, "tags": [ { diff --git a/frontend/src/mocks/data/reviewDetail.json b/frontend/src/mocks/data/reviewDetail.json new file mode 100644 index 000000000..4439f7d43 --- /dev/null +++ b/frontend/src/mocks/data/reviewDetail.json @@ -0,0 +1,29 @@ +{ + "reviews": { + "id": 1, + "userName": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", + "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", + "rating": 4.5, + "tags": [ + { + "id": 5, + "name": "단짠단짠", + "tagType": "TASTE" + }, + { + "id": 1, + "name": "망고망고", + "tagType": "TASTE" + } + ], + "content": "맛있어용~!~!", + "rebuy": true, + "favoriteCount": 1320, + "favorite": true, + "createdAt": "2023-10-13T00:00:00", + "categoryType": "food", + "productId": 1, + "productName": "칠성 사이다" + } +} diff --git a/frontend/src/mocks/handlers/reviewHandlers.ts b/frontend/src/mocks/handlers/reviewHandlers.ts index 455714f52..5c007c605 100644 --- a/frontend/src/mocks/handlers/reviewHandlers.ts +++ b/frontend/src/mocks/handlers/reviewHandlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { isReviewSortOption, isSortOrder } from './utils'; +import mockReviewDetail from '../data/reviewDetail.json'; import mockReviewRanking from '../data/reviewRankingList.json'; import mockReviews from '../data/reviews.json'; import mockReviewTags from '../data/reviewTagList.json'; @@ -73,4 +74,8 @@ export const reviewHandlers = [ rest.get('/api/tags', (_, res, ctx) => { return res(ctx.status(200), ctx.json(mockReviewTags)); }), + + rest.get('/api/reviews/:reviewId', (_, res, ctx) => { + return res(ctx.status(200), ctx.json(mockReviewDetail)); + }), ]; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 12df226e0..f53b61282 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -54,7 +54,7 @@ export const ProductDetailPage = () => { return null; } - const { name, bookmark, reviewCount } = productDetail; + const { name, reviewCount } = productDetail; const tabMenus = [`리뷰 ${reviewCount}`, '꿀조합']; const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; @@ -84,7 +84,7 @@ export const ProductDetailPage = () => { return ( - + diff --git a/frontend/src/pages/ReviewDetailPage.tsx b/frontend/src/pages/ReviewDetailPage.tsx new file mode 100644 index 000000000..7447e88e2 --- /dev/null +++ b/frontend/src/pages/ReviewDetailPage.tsx @@ -0,0 +1,132 @@ +import { Badge, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { SectionTitle, SvgIcon, TagList } from '@/components/Common'; +import { PATH } from '@/constants/path'; +import { useReviewDetailQuery } from '@/hooks/queries/review'; +import { getRelativeDate } from '@/utils/date'; + +export const ReviewDetailPage = () => { + const { reviewId } = useParams(); + const { data: reviewDetail } = useReviewDetailQuery(Number(reviewId)); + + const { + productName, + categoryType, + productId, + profileImage, + userName, + rating, + createdAt, + rebuy, + image, + tags, + content, + favoriteCount, + } = reviewDetail.reviews; + + const theme = useTheme(); + + return ( + + + + + + + +
    + {userName} + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + {getRelativeDate(createdAt)} + + +
    +
    + {rebuy && ( + + 😝 또 살래요 + + )} +
    + {image && } + + {content} + + + + {favoriteCount} + + +
    +
    + ); +}; + +const ReviewDetailPageContainer = styled.div` + padding: 20px 20px 0; +`; + +const ReviewItemContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 20px; +`; + +const ReviewerWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const ReviewerInfoWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 10px; +`; + +const RebuyBadge = styled(Badge)` + font-weight: ${({ theme }) => theme.fontWeights.bold}; +`; + +const ReviewerImage = styled.img` + border: 2px solid ${({ theme }) => theme.colors.primary}; + border-radius: 50%; + object-fit: cover; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + margin-left: -2px; + + & > span { + margin-left: 12px; + } +`; + +const ReviewImage = styled.img` + align-self: center; +`; + +const ReviewContent = styled(Text)` + white-space: pre-wrap; +`; + +const FavoriteWrapper = styled.div` + display: flex; + align-items: center; + padding: 0; + column-gap: 8px; +`; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 739785556..d045e8dc3 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -51,6 +51,15 @@ const router = createBrowserRouter([ return { Component: MemberRecipePage }; }, }, + { + path: `${PATH.REVIEW}/:reviewId`, + async lazy() { + const { ReviewDetailPage } = await import( + /* webpackChunkName: "ReviewDetailPage" */ '@/pages/ReviewDetailPage' + ); + return { Component: ReviewDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts index 391cb103d..b3389a32f 100644 --- a/frontend/src/types/product.ts +++ b/frontend/src/types/product.ts @@ -17,7 +17,6 @@ export interface ProductDetail { content: string; averageRating: number; reviewCount: number; - bookmark: boolean; tags: Tag[]; } diff --git a/frontend/src/types/ranking.ts b/frontend/src/types/ranking.ts index 9f0707f68..01c669e0f 100644 --- a/frontend/src/types/ranking.ts +++ b/frontend/src/types/ranking.ts @@ -1,3 +1,4 @@ +import type { CategoryVariant } from './common'; import type { Member } from './member'; import type { Product } from './product'; @@ -10,7 +11,7 @@ export interface ReviewRanking { content: string; rating: number; favoriteCount: number; - categoryType: string; + categoryType: CategoryVariant; } export interface RecipeRanking { diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index fa17b469a..42c21a24d 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,7 +1,7 @@ import type { Product } from './product'; import type { ProductRanking, RecipeRanking, ReviewRanking } from './ranking'; import type { Comment, MemberRecipe, Recipe } from './recipe'; -import type { Review } from './review'; +import type { Review, ReviewDetail } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; export interface Page { @@ -63,7 +63,9 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } - +export interface ReviewDetailResponse { + reviews: ReviewDetail; +} export interface CommentResponse { hasNext: boolean; totalElements: number | null; diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index debf6f65e..ced1d2f58 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -1,4 +1,4 @@ -import type { Tag, TagVariants } from './common'; +import type { CategoryVariant, Tag, TagVariants } from './common'; export interface Review { id: number; @@ -14,6 +14,12 @@ export interface Review { favorite: boolean; } +export interface ReviewDetail extends Review { + categoryType: CategoryVariant; + productId: number; + productName: string; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[]; diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts index 75d5d816d..78f293f7f 100644 --- a/frontend/src/types/search.ts +++ b/frontend/src/types/search.ts @@ -1,7 +1,8 @@ +import type { CategoryVariant } from './common'; import type { Product } from './product'; export interface ProductSearchResult extends Product { - categoryType: string; + categoryType: CategoryVariant; } export type ProductSearchAutocomplete = Pick; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index da508d2b9..3cdae7d06 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1375,10 +1375,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== -"@fun-eat/design-system@^0.3.13": - version "0.3.13" - resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.13.tgz#fbb48efff05c95883889dff280e118204de6d459" - integrity sha512-+wlTfWAJ3Z0ZmnJ2GyxX+HSQB8eB3g9PY8Blemv8nAk5ppuWbB9UKjnhebNgdtbtq+AN4HezKmbNl1Y+prxcWA== +"@fun-eat/design-system@^0.3.15": + version "0.3.15" + resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.15.tgz#61a9a01a82f84fa5627c49bd646cb72ca9e648c8" + integrity sha512-uhn5UZWfvQhNz/2sOoMwDr7Hj7SSx94bN35jifuYpm7ju0A8LHfivmu0mAbrMojuQ6XKYf0ZUME8FMMHwpw9Fg== "@humanwhocodes/config-array@^0.11.11": version "0.11.11" @@ -4497,6 +4497,13 @@ browser-assert@^1.2.1: resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200" integrity sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ== +browser-image-compression@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.2.tgz#4d5ef8882e9e471d6d923715ceb9034499d14eaa" + integrity sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + dependencies: + uzip "0.20201231.0" + browserify-zlib@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" @@ -11068,6 +11075,11 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uzip@0.20201231.0: + version "0.20201231.0" + resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14" + integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: version "9.1.2" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.2.tgz#51168df21c8ca01c83285f27316549b2c51a5b46" From 62124a6139fc53fd65b03b18097f0c9597b323dc Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Mon, 16 Oct 2023 12:58:36 +0900 Subject: [PATCH 02/20] =?UTF-8?q?[FE]=20feat:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 리뷰 소수 한자리까지로 수정 * feat: 랭킹 업데이트 텍스트 추가 * feat: 랭킹 기준에 info 아이콘 추가 * feat: 스토리북 프리뷰에 info svg 추가 --- frontend/.storybook/preview-body.html | 5 ++ .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 3 ++ .../ProductDetailItem/ProductDetailItem.tsx | 2 +- .../Product/ProductItem/ProductItem.tsx | 2 +- frontend/src/pages/HomePage.tsx | 46 ++++++++++++++++--- 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 9e1c31c57..1461bde42 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -114,6 +114,11 @@ d="M232 127.89a16 16 0 0 1-8.18 14L55.91 237.9A16.14 16.14 0 0 1 48 240a16 16 0 0 1-15.05-21.34l27.35-79.95a4 4 0 0 1 3.79-2.71H136a8 8 0 0 0 8-8.53a8.19 8.19 0 0 0-8.26-7.47H64.16a4 4 0 0 1-3.79-2.7l-27.44-80a16 16 0 0 1 22.92-19.23l168 95.89a16 16 0 0 1 8.15 13.93Z" /> + + +
    diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index b8be9a974..0705d543a 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -23,6 +23,7 @@ export const SVG_ICON_VARIANTS = [ 'camera', 'link', 'plane', + 'info' ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index 65cf49d3d..450c870be 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -83,6 +83,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx index 52cf05ada..ab7437f17 100644 --- a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx @@ -42,7 +42,7 @@ const ProductDetailItem = ({ category, productDetail }: ProductDetailItemProps) 평균 평점 - {averageRating} + {averageRating.toFixed(1)} diff --git a/frontend/src/components/Product/ProductItem/ProductItem.tsx b/frontend/src/components/Product/ProductItem/ProductItem.tsx index 74fe7accb..32300d2bc 100644 --- a/frontend/src/components/Product/ProductItem/ProductItem.tsx +++ b/frontend/src/components/Product/ProductItem/ProductItem.tsx @@ -43,7 +43,7 @@ const ProductItem = ({ product }: ProductItemProps) => { - {averageRating} + {averageRating.toFixed(1)} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 436b893ec..bb42cecba 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,15 +1,23 @@ -import { Heading, Spacing } from '@fun-eat/design-system'; +import { Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { Suspense } from 'react'; import styled from 'styled-components'; -import { Loading, ErrorBoundary, ErrorComponent, CategoryFoodList, CategoryStoreList } from '@/components/Common'; +import { + Loading, + ErrorBoundary, + ErrorComponent, + CategoryFoodList, + CategoryStoreList, + SvgIcon, +} from '@/components/Common'; import { ProductRankingList, ReviewRankingList, RecipeRankingList } from '@/components/Rank'; import { IMAGE_URL } from '@/constants'; import channelTalk from '@/service/channelTalk'; export const HomePage = () => { const { reset } = useQueryErrorResetBoundary(); + const theme = useTheme(); channelTalk.loadScript(); @@ -41,7 +49,12 @@ export const HomePage = () => { 🍯 꿀조합 랭킹 - + + + + 꿀조합 랭킹은 자체 알고리즘 기반으로 업데이트됩니다. + + }> @@ -51,9 +64,14 @@ export const HomePage = () => { - 👑 상품 랭킹 + 🍙 상품 랭킹 - + + + + 상품 랭킹은 2주 단위로 업데이트됩니다. + + }> @@ -65,7 +83,12 @@ export const HomePage = () => { 📝 리뷰 랭킹 - + + + + 리뷰 랭킹은 자체 알고리즘 기반으로 업데이트됩니다. + + }> @@ -100,3 +123,14 @@ const CategoryListWrapper = styled.div` display: none; } `; + +const RankingInfoWrapper = styled.div` + display: flex; + align-items: center; + gap: 2px; + margin: 8px 0 16px; + + & > svg { + padding-bottom: 2px; + } +`; From 5d2e717eba11319c699a923e95e10d8d64fdb12e Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:34:50 +0900 Subject: [PATCH 03/20] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 해당 review에 달린 tag를 삭제하는 기능 추가 * feat: 해당 review에 달린 favorite을 삭제하는 기능 추가 * feat: NotAuthorOfReviewException 추가 * feat: 리뷰 삭제 기능 구현 * feat: s3 이미지 삭제 기능 구현 * test: 리뷰 삭제 기능에 대한 인수테스트 작성 * refactor: 리뷰 반영 * refactor: deleteAllByIdInBatch적용 * test: 리뷰 삭제 실패 케이스 추가 * refactor: updateProductImage 메서드 중복 제거 * feat: s3 파일 경로 지정 로직 추가 * refactor: 리뷰에 이미지가 존재할 때에만 s3 delete 로직 실행하도록 수정 * refactor: 리뷰 삭제 성공시 상태코드 204 반환 * test: 리뷰 삭제 성공시 상태코드 204 반환하도록 인수테스트 수정 * feat: s3 이미지 삭제 로직 이벤트 처리 * refactor: 이미지 있을 때만 이벤트 발행하던 로직을 이미지 유무 상관없이 이벤트 발행하도록 수정 (이미지 유무 처리를 이벤트리스너에서 하도록) * test: 리뷰 삭제 이벤트 관련 테스트 추가 * test: 리뷰 삭제 이벤트 관련 테스트 보완 * refactor: ReviewTagRepositoryTest의 deleteByReview 테스트 간소화 * feat: application.yml에 스레드 풀 설정 추가 * refactor: member를 equals로 비교하도록 수정 * chore: 컨벤션 적용 * refactor: 세션 이름 복구 * refactor: 리뷰 반영 * refactor: reviewId 대신 review로 delete하도록 수정 * refactor: s3 이미지 삭제 실패 로그 문구 수정 * refactor: 리뷰 삭제시 deleteById 대신 delete로 수정 * feat: 리뷰 삭제 api 수정 사항 적용 * style: EventTest 메소드 줄바꿈 --- .../java/com/funeat/FuneatApplication.java | 3 +- .../java/com/funeat/common/ImageUploader.java | 2 + .../common/exception/CommonException.java | 6 + .../java/com/funeat/common/s3/S3Uploader.java | 19 ++ .../persistence/ReviewFavoriteRepository.java | 5 + .../presentation/MemberApiController.java | 11 + .../member/presentation/MemberController.java | 11 + .../review/application/ReviewDeleteEvent.java | 14 ++ .../ReviewDeleteEventListener.java | 27 +++ .../review/application/ReviewService.java | 57 ++++- .../java/com/funeat/review/domain/Review.java | 4 + .../review/exception/ReviewErrorCode.java | 1 + .../review/exception/ReviewException.java | 6 + .../persistence/ReviewTagRepository.java | 5 + .../presentation/ReviewApiController.java | 6 +- .../review/presentation/ReviewController.java | 4 +- backend/src/main/resources/application.yml | 5 + .../member/MemberAcceptanceTest.java | 82 +++++++ .../funeat/acceptance/member/MemberSteps.java | 9 + .../java/com/funeat/common/EventTest.java | 66 ++++++ .../com/funeat/common/TestImageUploader.java | 4 + .../ReviewFavoriteRepositoryTest.java | 75 ++++++ .../ReviewDeleteEventListenerTest.java | 217 ++++++++++++++++++ .../review/application/ReviewServiceTest.java | 169 ++++++++++++-- .../persistence/ReviewTagRepositoryTest.java | 74 ++++++ 25 files changed, 858 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java create mode 100644 backend/src/test/java/com/funeat/common/EventTest.java create mode 100644 backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java diff --git a/backend/src/main/java/com/funeat/FuneatApplication.java b/backend/src/main/java/com/funeat/FuneatApplication.java index 53bd185c0..34909202c 100644 --- a/backend/src/main/java/com/funeat/FuneatApplication.java +++ b/backend/src/main/java/com/funeat/FuneatApplication.java @@ -3,8 +3,10 @@ import com.funeat.common.repository.BaseRepositoryImpl; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +@EnableAsync @SpringBootApplication @EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class) public class FuneatApplication { @@ -12,5 +14,4 @@ public class FuneatApplication { public static void main(String[] args) { SpringApplication.run(FuneatApplication.class, args); } - } diff --git a/backend/src/main/java/com/funeat/common/ImageUploader.java b/backend/src/main/java/com/funeat/common/ImageUploader.java index 754b1affd..afd4b5c10 100644 --- a/backend/src/main/java/com/funeat/common/ImageUploader.java +++ b/backend/src/main/java/com/funeat/common/ImageUploader.java @@ -5,4 +5,6 @@ public interface ImageUploader { String upload(final MultipartFile image); + + void delete(final String fileName); } diff --git a/backend/src/main/java/com/funeat/common/exception/CommonException.java b/backend/src/main/java/com/funeat/common/exception/CommonException.java index e2e822c68..55be12d5d 100644 --- a/backend/src/main/java/com/funeat/common/exception/CommonException.java +++ b/backend/src/main/java/com/funeat/common/exception/CommonException.java @@ -22,4 +22,10 @@ public S3UploadFailException(final CommonErrorCode errorCode) { super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); } } + + public static class S3DeleteFailException extends CommonException { + public S3DeleteFailException(final CommonErrorCode errorCode) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + } } diff --git a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java index 3f9c86caa..97e6241b7 100644 --- a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java +++ b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java @@ -3,15 +3,19 @@ import static com.funeat.exception.CommonErrorCode.IMAGE_EXTENSION_ERROR_CODE; import static com.funeat.exception.CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE; +import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; import com.funeat.common.ImageUploader; import com.funeat.common.exception.CommonException.NotAllowedFileExtensionException; +import com.funeat.common.exception.CommonException.S3DeleteFailException; import com.funeat.common.exception.CommonException.S3UploadFailException; import java.io.IOException; import java.util.List; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -21,8 +25,11 @@ @Profile("!test") public class S3Uploader implements ImageUploader { + private static final int BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH = 31; private static final List INCLUDE_EXTENSIONS = List.of("image/jpeg", "image/png", "image/webp"); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + @Value("${cloud.aws.s3.bucket}") private String bucket; @@ -53,6 +60,18 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String image) { + final String imageName = image.substring(BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH); + try { + final String key = folder + imageName; + amazonS3.deleteObject(bucket, key); + } catch (final AmazonServiceException e) { + log.error("S3 이미지 삭제에 실패했습니다. 이미지 경로 : {}", image); + throw new S3DeleteFailException(UNKNOWN_SERVER_ERROR_CODE); + } + } + private void validateExtension(final MultipartFile image) { final String contentType = image.getContentType(); if (!INCLUDE_EXTENSIONS.contains(contentType)) { diff --git a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java index 2e96e623a..f1ae40e5d 100644 --- a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java +++ b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java @@ -3,10 +3,15 @@ import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.review.domain.Review; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewFavoriteRepository extends JpaRepository { Optional findByMemberAndReview(final Member member, final Review review); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java index 6ee963d54..af00932f7 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -15,7 +15,9 @@ 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.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -69,4 +71,13 @@ public ResponseEntity getMemberRecipe(@AuthenticationPrin return ResponseEntity.ok().body(response); } + + @Logging + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo) { + reviewService.deleteReview(reviewId, loginInfo.getId()); + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java index 5d5748fd7..9c5e60763 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -12,7 +12,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; 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.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -55,4 +57,13 @@ ResponseEntity getMemberReview(@AuthenticationPrincipal f @GetMapping ResponseEntity getMemberRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PageableDefault final Pageable pageable); + + @Operation(summary = "리뷰 삭제", description = "자신이 작성한 리뷰를 삭제한다.") + @ApiResponse( + responseCode = "204", + description = "리뷰 삭제 성공." + ) + @DeleteMapping + ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo); } diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java new file mode 100644 index 000000000..7c69eee3c --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java @@ -0,0 +1,14 @@ +package com.funeat.review.application; + +public class ReviewDeleteEvent { + + private final String image; + + public ReviewDeleteEvent(final String image) { + this.image = image; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java new file mode 100644 index 000000000..2009e3936 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java @@ -0,0 +1,27 @@ +package com.funeat.review.application; + +import com.funeat.common.ImageUploader; +import io.micrometer.core.instrument.util.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class ReviewDeleteEventListener { + + private final ImageUploader imageUploader; + + public ReviewDeleteEventListener(final ImageUploader imageUploader) { + this.imageUploader = imageUploader; + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void deleteReviewImageInS3(final ReviewDeleteEvent event) { + final String image = event.getImage(); + if (StringUtils.isBlank(image)) { + imageUploader.delete(image); + } + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index ee482a8c5..027f8d5ae 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -3,6 +3,7 @@ import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE; import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND; import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; +import static com.funeat.review.exception.ReviewErrorCode.NOT_AUTHOR_OF_REVIEW; import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import com.funeat.common.ImageUploader; @@ -27,6 +28,7 @@ import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.dto.SortingReviewsResponse; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; @@ -35,6 +37,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -58,11 +61,13 @@ public class ReviewService { private final ProductRepository productRepository; private final ReviewFavoriteRepository reviewFavoriteRepository; private final ImageUploader imageUploader; + private final ApplicationEventPublisher eventPublisher; public ReviewService(final ReviewRepository reviewRepository, final TagRepository tagRepository, final ReviewTagRepository reviewTagRepository, final MemberRepository memberRepository, final ProductRepository productRepository, - final ReviewFavoriteRepository reviewFavoriteRepository, final ImageUploader imageUploader) { + final ReviewFavoriteRepository reviewFavoriteRepository, + final ImageUploader imageUploader, final ApplicationEventPublisher eventPublisher) { this.reviewRepository = reviewRepository; this.tagRepository = tagRepository; this.reviewTagRepository = reviewTagRepository; @@ -70,6 +75,7 @@ public ReviewService(final ReviewRepository reviewRepository, final TagRepositor this.productRepository = productRepository; this.reviewFavoriteRepository = reviewFavoriteRepository; this.imageUploader = imageUploader; + this.eventPublisher = eventPublisher; } @Transactional @@ -124,14 +130,11 @@ private ReviewFavorite saveReviewFavorite(final Member member, final Review revi } @Transactional - public void updateProductImage(final Long reviewId) { - final Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + public void updateProductImage(final Long productId) { + final Product product = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Product product = review.getProduct(); - final Long productId = product.getId(); final PageRequest pageRequest = PageRequest.of(TOP, ONE); - final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); if (!topFavoriteReview.isEmpty()) { final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage(); @@ -180,6 +183,46 @@ public MemberReviewsResponse findReviewByMember(final Long memberId, final Pagea return MemberReviewsResponse.toResponse(pageDto, dtos); } + @Transactional + public void deleteReview(final Long reviewId, final Long memberId) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); + final Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + final Product product = review.getProduct(); + final String image = review.getImage(); + + if (review.checkAuthor(member)) { + eventPublisher.publishEvent(new ReviewDeleteEvent(image)); + deleteThingsRelatedToReview(review); + updateProductImage(product.getId()); + return; + } + throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId); + } + + private void deleteThingsRelatedToReview(final Review review) { + deleteReviewTags(review); + deleteReviewFavorites(review); + reviewRepository.delete(review); + } + + private void deleteReviewTags(final Review review) { + final List reviewTags = reviewTagRepository.findByReview(review); + final List ids = reviewTags.stream() + .map(ReviewTag::getId) + .collect(Collectors.toList()); + reviewTagRepository.deleteAllByIdInBatch(ids); + } + + private void deleteReviewFavorites(final Review review) { + final List reviewFavorites = reviewFavoriteRepository.findByReview(review); + final List ids = reviewFavorites.stream() + .map(ReviewFavorite::getId) + .collect(Collectors.toList()); + reviewFavoriteRepository.deleteAllByIdInBatch(ids); + } + public Optional getMostFavoriteReview(final Long productId) { final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index 3545371e3..d990666d3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -88,6 +88,10 @@ public void minusFavoriteCount() { this.favoriteCount--; } + public boolean checkAuthor(final Member member) { + return Objects.equals(this.member, member); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java index d91c0c8c3..05331dac9 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java @@ -5,6 +5,7 @@ public enum ReviewErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"), + NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002") ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewException.java b/backend/src/main/java/com/funeat/review/exception/ReviewException.java index 4699f3af6..a961f3301 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewException.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewException.java @@ -15,4 +15,10 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), reviewId)); } } + + public static class NotAuthorOfReviewException extends ReviewException { + public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId)); + } + } } diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java index 7129a711c..cbdf3c3bf 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java @@ -1,5 +1,6 @@ package com.funeat.review.persistence; +import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; import com.funeat.tag.domain.Tag; import java.util.List; @@ -16,4 +17,8 @@ public interface ReviewTagRepository extends JpaRepository { + "GROUP BY rt.tag " + "ORDER BY cnt DESC") List findTop3TagsByReviewIn(final Long productId, final Pageable pageable); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } 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 57bf20359..ba094cb14 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -17,6 +17,7 @@ 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -49,11 +50,12 @@ public ResponseEntity writeReview(@PathVariable final Long productId, @Logging @PatchMapping("/api/products/{productId}/reviews/{reviewId}") - public ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + public ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody @Valid final ReviewFavoriteRequest request) { reviewService.likeReview(reviewId, loginInfo.getId(), request); - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(productId); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 886ee5a15..2e3d52459 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -42,7 +43,8 @@ ResponseEntity writeReview(@PathVariable final Long productId, description = "리뷰 좋아요(취소) 성공." ) @PatchMapping - ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody final ReviewFavoriteRequest request); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8235adae4..b15ff14fe 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -6,6 +6,11 @@ spring: enabled: true maxFileSize: 10MB maxRequestSize: 15MB + task: + execution: + pool: + core-size: { THREAD_CORE_SIZE } + max-size: { THREAD_MAX_SIZE } session: store-type: jdbc jdbc: diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java index cb3b8e629..b2a94b2c0 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -8,7 +8,9 @@ import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; 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.common.CommonSteps.페이지를_검증한다; +import static com.funeat.acceptance.member.MemberSteps.리뷰_삭제_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_꿀조합_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_리뷰_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_정보_수정_요청; @@ -33,17 +35,22 @@ import static com.funeat.fixture.PageFixture.총_데이터_개수; import static com.funeat.fixture.PageFixture.총_페이지; import static com.funeat.fixture.PageFixture.최신순; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.RecipeFixture.레시피; import static com.funeat.fixture.RecipeFixture.레시피1; import static com.funeat.fixture.RecipeFixture.레시피2; import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; +import static com.funeat.fixture.ReviewFixture.리뷰1; +import static com.funeat.fixture.ReviewFixture.리뷰2; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매X_생성; import static com.funeat.fixture.ScoreFixture.점수_1점; import static com.funeat.fixture.ScoreFixture.점수_2점; import static com.funeat.fixture.ScoreFixture.점수_3점; +import static com.funeat.fixture.ScoreFixture.점수_4점; import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; @@ -300,6 +307,81 @@ class getMemberRecipes_실패_테스트 { } } + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 로그인하지_않는_사용자가_리뷰_삭제시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(cookie, 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 존재하지_않는_리뷰를_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰2); + + // then + STATUS_CODE를_검증한다(응답, 찾을수_없음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REVIEW_NOT_FOUND.getCode(), REVIEW_NOT_FOUND.getMessage()); + } + + @Test + void 자신이_작성하지_않은_리뷰는_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버2), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 잘못된_요청); + } + } + private void 사용자_리뷰_조회_결과를_검증한다(final ExtractableResponse response, final int expectedReviewSize) { final var actual = response.jsonPath().getList("reviews", MemberReviewDto.class); diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index ca5600fdc..681efb26a 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -62,4 +62,13 @@ public class MemberSteps { .then() .extract(); } + + public static ExtractableResponse 리뷰_삭제_요청(final String loginCookie, final Long reviewId) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .delete("/api/members/reviews/{reviewId}", reviewId) + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/common/EventTest.java b/backend/src/test/java/com/funeat/common/EventTest.java new file mode 100644 index 000000000..dec401bec --- /dev/null +++ b/backend/src/test/java/com/funeat/common/EventTest.java @@ -0,0 +1,66 @@ +package com.funeat.common; + +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.application.ReviewService; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +@SpringBootTest +@RecordApplicationEvents +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(ReplaceUnderscores.class) +public class EventTest { + + @Autowired + protected ApplicationEvents events; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected ReviewService reviewService; + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + tagRepository.saveAll(tags); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } +} diff --git a/backend/src/test/java/com/funeat/common/TestImageUploader.java b/backend/src/test/java/com/funeat/common/TestImageUploader.java index 58d4ab6f8..642da2176 100644 --- a/backend/src/test/java/com/funeat/common/TestImageUploader.java +++ b/backend/src/test/java/com/funeat/common/TestImageUploader.java @@ -30,6 +30,10 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String fileName) { + } + private void deleteDirectory(Path directory) throws IOException { // 디렉토리 내부 파일 및 디렉토리 삭제 try (Stream pathStream = Files.walk(directory)) { diff --git a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java index 73ed00553..fcb190d28 100644 --- a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java +++ b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java @@ -3,6 +3,7 @@ import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; @@ -11,6 +12,7 @@ import com.funeat.common.RepositoryTest; import com.funeat.member.domain.favorite.ReviewFavorite; +import java.util.List; import java.util.NoSuchElementException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -106,4 +108,77 @@ class findByMemberAndReview_실패_테스트 { .isInstanceOf(NoSuchElementException.class); } } + + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_좋아요를_삭제할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product, 0L); + final var review2 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewFavorite1_1 = ReviewFavorite.create(member1, review1, true); + final var reviewFavorite1_2 = ReviewFavorite.create(member2, review1, true); + final var reviewFavorite1_3 = ReviewFavorite.create(member3, review1, true); + final var reviewFavorite2_1 = ReviewFavorite.create(member1, review2, true); + final var reviewFavorite2_2 = ReviewFavorite.create(member2, review2, true); + 복수_리뷰_좋아요_저장(reviewFavorite1_1, reviewFavorite1_2, reviewFavorite1_3, reviewFavorite2_1, reviewFavorite2_2); + + final var expected = List.of(reviewFavorite2_1, reviewFavorite2_2); + + // when + reviewFavoriteRepository.deleteByReview(review1); + + // then + final var remainings = reviewFavoriteRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 리뷰로_해당_리뷰에_달린_좋아요를_조회할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewFavorite = ReviewFavorite.create(member, review, true); + 단일_리뷰_좋아요_저장(reviewFavorite); + + final var expected = List.of(reviewFavorite); + + // when + final var actual = reviewFavoriteRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java new file mode 100644 index 000000000..5edf33f36 --- /dev/null +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -0,0 +1,217 @@ +package com.funeat.review.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.ImageFixture.이미지_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import com.funeat.common.EventTest; +import com.funeat.common.ImageUploader; +import com.funeat.common.exception.CommonException.S3DeleteFailException; +import com.funeat.exception.CommonErrorCode; +import com.funeat.tag.domain.Tag; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; + +class ReviewDeleteEventListenerTest extends EventTest { + + @MockBean + private ImageUploader uploader; + + @Nested + class 리뷰_삭제_이벤트_발행 { + + @Test + void 리뷰_작성자가_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(1); + } + + @Test + void 리뷰_작성자가_아닌_사람이_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행되지_않는다() { + // given + final var author = 멤버_멤버2_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + try { + reviewService.deleteReview(reviewId, memberId); + } catch (Exception ignored) { + } + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(0); + } + } + + @Nested + class 이미지_삭제_로직_작동 { + + @Test + void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(1)).delete(any()); + } + + @Test + void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, null, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(0)).delete(any()); + } + + @Test + void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) + .when(uploader) + .delete(any()); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + assertThat(reviewRepository.findById(reviewId)).isEmpty(); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 00c3ed691..e6ca43b22 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -40,6 +40,7 @@ import com.funeat.review.domain.Review; import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; import java.util.List; @@ -614,7 +615,7 @@ class updateProductImage_성공_테스트 { final var expected = review.getImage(); // when - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -643,7 +644,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -672,7 +673,7 @@ class updateProductImage_성공_테스트 { final var expected = firstReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -701,7 +702,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -722,11 +723,11 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var thirdReview = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 1L); final var thirdReviewId = 단일_리뷰_저장(thirdReview); @@ -734,7 +735,7 @@ class updateProductImage_성공_테스트 { final var expected = thirdReview.getImage(); // when - reviewService.updateProductImage(thirdReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -755,7 +756,7 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); @@ -763,7 +764,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -775,7 +776,7 @@ class updateProductImage_성공_테스트 { class updateProductImage_실패_테스트 { @Test - void 존재하지_않는_리뷰로_상품_업데이트를_시도하면_예외가_발생한다() { + void 존재하지_않는_상품으로_상품_업데이트를_시도하면_예외가_발생한다() { // given final var member = 멤버_멤버1_생성(); 단일_멤버_저장(member); @@ -786,13 +787,155 @@ class updateProductImage_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); 단일_상품_저장(product); - final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); - final var wrongReviewId = 단일_리뷰_저장(review) + 1L; + final var wrongProductId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.updateProductImage(wrongProductId)) + .isInstanceOf(ProductNotFoundException.class); + } + } + + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_생성(true); + reviewService.likeReview(reviewId, authorId, favoriteRequest); + reviewService.likeReview(reviewId, memberId, favoriteRequest); + + // when + reviewService.deleteReview(reviewId, authorId); + + // then + final var tags = reviewTagRepository.findAll(); + final var favorites = reviewFavoriteRepository.findAll(); + final var findReview = reviewRepository.findById(reviewId); + + assertSoftly(soft -> { + soft.assertThat(tags).isEmpty(); + soft.assertThat(favorites).isEmpty(); + soft.assertThat(findReview).isEmpty(); + }); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @Test + void 존재하지_않는_사용자가_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var wrongMemberId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, wrongMemberId)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 존재하지_않는_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var wrongReviewId = 999L; // when & then - assertThatThrownBy(() -> reviewService.updateProductImage(wrongReviewId)) + assertThatThrownBy(() -> reviewService.deleteReview(wrongReviewId, authorId)) .isInstanceOf(ReviewNotFoundException.class); } + + @Test + void 자신이_작성하지_않은_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, memberId)) + .isInstanceOf(NotAuthorOfReviewException.class); + } } @Nested diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java index baab6abdb..4f5bbf152 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java @@ -72,6 +72,80 @@ class findTop3TagsByReviewIn_성공_테스트 { } } + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_삭제할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review1 = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewTag1_1 = 리뷰_태그_생성(review1, tag1); + final var reviewTag2_1 = 리뷰_태그_생성(review2, tag1); + 복수_리뷰_태그_저장(reviewTag1_1, reviewTag2_1); + + final var expected = List.of(reviewTag2_1); + + // when + reviewTagRepository.deleteByReview(review1); + + // then + final var remainings = reviewTagRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_확인할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewTag = 리뷰_태그_생성(review, tag1); + 단일_리뷰_태그_저장(reviewTag); + + final var expected = List.of(reviewTag); + + // when + final var actual = reviewTagRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } + private ReviewTag 리뷰_태그_생성(final Review review, final Tag tag) { return ReviewTag.createReviewTag(review, tag); } From dab066eb4d0336baba88db38073807c8bfa5a8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EA=B0=80?= Date: Mon, 16 Oct 2023 13:56:28 +0900 Subject: [PATCH 04/20] =?UTF-8?q?[BE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove: 북마크 관련 삭제 * feat: 꿀조합 댓글 작성 구현 * refactor: Comments 단방향으로 수정 * feat: 꿀조합 댓글 조회 기능 추가 * refactor: Specification private 기본생성자 추가 * refactor: 적용된 코드 SESSION ID 수정 * refactor: 생성자 정렬 수정 * refactor: 세션 쿠키 이름 SESSION 으로 수정 * refactor: 변수명 상세하게 specification 로 수정 * refactor: repeat 사용과 디버깅 출력 코드 삭제 * remove: 디버깅 출력 코드 삭제 * refactor: subList() 와 for each 사용으로 수정 * test: 꿀조합 댓글 관련 서비스 테스트 추가 * refactor: 응답 변수명 상세하게 수정 * refactor: toResponse 맞춰서 수정 * refactor: 메소드 순서에 맞게 수정 * refactor: 리뷰 반영 * refactor: 테스트 실패 수정 --- .../AdminProductSpecification.java | 3 + .../AdminReviewSpecification.java | 3 + .../com/funeat/comment/domain/Comment.java | 59 +++++ .../persistence/CommentRepository.java | 8 + .../specification/CommentSpecification.java | 56 +++++ .../domain/bookmark/ProductBookmark.java | 28 --- .../domain/bookmark/RecipeBookmark.java | 28 --- .../ProductBookmarkRepository.java | 7 - .../persistence/RecipeBookMarkRepository.java | 7 - .../com/funeat/product/domain/Product.java | 4 - .../recipe/application/RecipeService.java | 79 ++++++- .../recipe/dto/RecipeCommentCondition.java | 20 ++ .../dto/RecipeCommentCreateRequest.java | 20 ++ .../dto/RecipeCommentMemberResponse.java | 26 +++ .../recipe/dto/RecipeCommentResponse.java | 42 ++++ .../recipe/dto/RecipeCommentsResponse.java | 34 +++ .../presentation/RecipeApiController.java | 22 ++ .../recipe/presentation/RecipeController.java | 24 ++ .../acceptance/common/AcceptanceTest.java | 4 + .../recipe/RecipeAcceptanceTest.java | 215 +++++++++++++++++- .../funeat/acceptance/recipe/RecipeSteps.java | 28 +++ .../funeat/acceptance/review/ReviewSteps.java | 1 - .../com/funeat/common/RepositoryTest.java | 8 - .../java/com/funeat/common/ServiceTest.java | 12 +- .../recipe/application/RecipeServiceTest.java | 198 +++++++++++++++- .../persistence/RecipeRepositoryTest.java | 4 +- .../review/application/ReviewServiceTest.java | 4 +- 27 files changed, 844 insertions(+), 100 deletions(-) create mode 100644 backend/src/main/java/com/funeat/comment/domain/Comment.java create mode 100644 backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java create mode 100644 backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java delete mode 100644 backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java delete mode 100644 backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java delete mode 100644 backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java delete mode 100644 backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java index a8e63b748..c48ea0305 100644 --- a/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java +++ b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java @@ -13,6 +13,9 @@ public class AdminProductSpecification { private static final List> COUNT_RESULT_TYPES = List.of(Long.class, long.class); + private AdminProductSpecification() { + } + public static Specification searchBy(final ProductSearchCondition condition) { return (root, query, criteriaBuilder) -> { if (!COUNT_RESULT_TYPES.contains(query.getResultType())) { diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java index b7c345f14..045147de5 100644 --- a/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java +++ b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java @@ -11,6 +11,9 @@ public class AdminReviewSpecification { + private AdminReviewSpecification() { + } + public static Specification searchBy(final ReviewSearchCondition condition) { return (root, query, criteriaBuilder) -> { if (query.getResultType() != Long.class && query.getResultType() != long.class) { diff --git a/backend/src/main/java/com/funeat/comment/domain/Comment.java b/backend/src/main/java/com/funeat/comment/domain/Comment.java new file mode 100644 index 000000000..4e6798b9d --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/domain/Comment.java @@ -0,0 +1,59 @@ +package com.funeat.comment.domain; + +import com.funeat.member.domain.Member; +import com.funeat.recipe.domain.Recipe; +import java.time.LocalDateTime; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + protected Comment() { + } + + public Comment(final Recipe recipe, final Member member, final String comment) { + this.recipe = recipe; + this.member = member; + this.comment = comment; + } + + public Long getId() { + return id; + } + + public String getComment() { + return comment; + } + + public Member getMember() { + return member; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java b/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java new file mode 100644 index 000000000..e40a47f67 --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java @@ -0,0 +1,8 @@ +package com.funeat.comment.persistence; + +import com.funeat.comment.domain.Comment; +import com.funeat.common.repository.BaseRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository, BaseRepository { +} diff --git a/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java b/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java new file mode 100644 index 000000000..db6c734bb --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java @@ -0,0 +1,56 @@ +package com.funeat.comment.specification; + +import com.funeat.comment.domain.Comment; +import com.funeat.recipe.domain.Recipe; +import java.util.List; +import java.util.Objects; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Path; +import org.springframework.data.jpa.domain.Specification; + +public class CommentSpecification { + + private CommentSpecification() { + } + + private static final List> COUNT_RESULT_TYPES = List.of(Long.class, long.class); + + public static Specification findAllByRecipe(final Recipe recipe, final Long lastCommentId) { + return (root, query, criteriaBuilder) -> { + if (!COUNT_RESULT_TYPES.contains(query.getResultType())) { + root.fetch("member", JoinType.LEFT); + } + + criteriaBuilder.desc(root.get("id")); + + return Specification + .where(lessThan(lastCommentId)) + .and(equalToRecipe(recipe)) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification lessThan(final Long commentId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(commentId)) { + return null; + } + + final Path commentIdPath = root.get("id"); + + return criteriaBuilder.lessThan(commentIdPath, commentId); + }; + } + + private static Specification equalToRecipe(final Recipe recipe) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(recipe)) { + return null; + } + + final Path recipePath = root.get("recipe"); + + return criteriaBuilder.equal(recipePath, recipe); + }; + } +} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java deleted file mode 100644 index c18c84b59..000000000 --- a/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.funeat.member.domain.bookmark; - -import com.funeat.member.domain.Member; -import com.funeat.product.domain.Product; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; - -@Entity -public class ProductBookmark { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "member_id") - private Member member; - - @ManyToOne - @JoinColumn(name = "product_id") - private Product product; - - private Boolean checked; -} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java deleted file mode 100644 index 9dc0b75ad..000000000 --- a/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.funeat.member.domain.bookmark; - -import com.funeat.member.domain.Member; -import com.funeat.recipe.domain.Recipe; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; - -@Entity -public class RecipeBookmark { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "member_id") - private Member member; - - @ManyToOne - @JoinColumn(name = "recipe_id") - private Recipe recipe; - - private Boolean checked; -} diff --git a/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java deleted file mode 100644 index c7651b592..000000000 --- a/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.funeat.member.persistence; - -import com.funeat.member.domain.bookmark.ProductBookmark; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductBookmarkRepository extends JpaRepository { -} diff --git a/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java deleted file mode 100644 index 4ed5cce46..000000000 --- a/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.funeat.member.persistence; - -import com.funeat.member.domain.bookmark.RecipeBookmark; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RecipeBookMarkRepository extends JpaRepository { -} diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index a485eaf55..512f77f8f 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -1,6 +1,5 @@ package com.funeat.product.domain; -import com.funeat.member.domain.bookmark.ProductBookmark; import com.funeat.review.domain.Review; import java.util.List; import javax.persistence.Entity; @@ -39,9 +38,6 @@ public class Product { @OneToMany(mappedBy = "product") private List productRecipes; - @OneToMany(mappedBy = "product") - private List productBookmarks; - private Long reviewCount = 0L; protected Product() { 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 d67fd7b79..d9ede67c3 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -5,6 +5,9 @@ import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; import static com.funeat.recipe.exception.RecipeErrorCode.RECIPE_NOT_FOUND; +import com.funeat.comment.domain.Comment; +import com.funeat.comment.persistence.CommentRepository; +import com.funeat.comment.specification.CommentSpecification; import com.funeat.common.ImageUploader; import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; @@ -26,6 +29,10 @@ 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; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; @@ -36,6 +43,7 @@ import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; import com.funeat.recipe.persistence.RecipeImageRepository; import com.funeat.recipe.persistence.RecipeRepository; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -43,6 +51,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -53,6 +63,8 @@ public class RecipeService { private static final int THREE = 3; private static final int TOP = 0; + private static final int RECIPE_COMMENT_PAGE_SIZE = 10; + private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; private final MemberRepository memberRepository; private final ProductRepository productRepository; @@ -60,18 +72,21 @@ public class RecipeService { private final RecipeRepository recipeRepository; private final RecipeImageRepository recipeImageRepository; private final RecipeFavoriteRepository recipeFavoriteRepository; + private final CommentRepository commentRepository; private final ImageUploader imageUploader; public RecipeService(final MemberRepository memberRepository, final ProductRepository productRepository, final ProductRecipeRepository productRecipeRepository, final RecipeRepository recipeRepository, final RecipeImageRepository recipeImageRepository, - final RecipeFavoriteRepository recipeFavoriteRepository, final ImageUploader imageUploader) { + final RecipeFavoriteRepository recipeFavoriteRepository, + final CommentRepository commentRepository, final ImageUploader imageUploader) { this.memberRepository = memberRepository; this.productRepository = productRepository; this.productRecipeRepository = productRecipeRepository; this.recipeRepository = recipeRepository; this.recipeImageRepository = recipeImageRepository; this.recipeFavoriteRepository = recipeFavoriteRepository; + this.commentRepository = commentRepository; this.imageUploader = imageUploader; } @@ -166,7 +181,8 @@ public void likeRecipe(final Long memberId, final Long recipeId, final RecipeFav recipeFavorite.updateFavorite(request.getFavorite()); } - private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe, final Boolean favorite) { + private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe, + final Boolean favorite) { try { final RecipeFavorite recipeFavorite = RecipeFavorite.create(member, recipe, favorite); return recipeFavoriteRepository.save(recipeFavorite); @@ -201,4 +217,63 @@ public RankingRecipesResponse getTop3Recipes() { .collect(Collectors.toList()); return RankingRecipesResponse.toResponse(dtos); } + + @Transactional + public Long writeCommentOfRecipe(final Long memberId, final Long recipeId, + final RecipeCommentCreateRequest request) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); + + final Recipe findRecipe = recipeRepository.findById(recipeId) + .orElseThrow(() -> new RecipeNotFoundException(RECIPE_NOT_FOUND, recipeId)); + + final Comment comment = new Comment(findRecipe, findMember, request.getComment()); + + final Comment savedComment = commentRepository.save(comment); + return savedComment.getId(); + } + + public RecipeCommentsResponse getCommentsOfRecipe(final Long recipeId, final RecipeCommentCondition condition) { + final Recipe findRecipe = recipeRepository.findById(recipeId) + .orElseThrow(() -> new RecipeNotFoundException(RECIPE_NOT_FOUND, recipeId)); + + final Specification specification = CommentSpecification.findAllByRecipe(findRecipe, + condition.getLastId()); + + final PageRequest pageable = PageRequest.of(0, DEFAULT_CURSOR_PAGINATION_SIZE, Sort.by("id").descending()); + + final Page commentPaginationResult = commentRepository.findAllForPagination(specification, pageable, + condition.getTotalElements()); + + final List recipeCommentResponses = getRecipeCommentResponses( + commentPaginationResult.getContent()); + + final Boolean hasNext = hasNextPage(commentPaginationResult); + + return RecipeCommentsResponse.toResponse(recipeCommentResponses, hasNext, + commentPaginationResult.getTotalElements()); + } + + private List getRecipeCommentResponses(final List findComments) { + final List recipeCommentResponses = new ArrayList<>(); + final int resultSize = getResultSize(findComments); + final List comments = findComments.subList(0, resultSize); + + for (final Comment comment : comments) { + final RecipeCommentResponse recipeCommentResponse = RecipeCommentResponse.toResponse(comment); + recipeCommentResponses.add(recipeCommentResponse); + } + return recipeCommentResponses; + } + + private int getResultSize(final List findComments) { + if (findComments.size() < DEFAULT_CURSOR_PAGINATION_SIZE) { + return findComments.size(); + } + return RECIPE_COMMENT_PAGE_SIZE; + } + + private Boolean hasNextPage(final Page findComments) { + return findComments.getContent().size() > RECIPE_COMMENT_PAGE_SIZE; + } } diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java new file mode 100644 index 000000000..dcb3cf2d1 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java @@ -0,0 +1,20 @@ +package com.funeat.recipe.dto; + +public class RecipeCommentCondition { + + private final Long lastId; + private final Long totalElements; + + public RecipeCommentCondition(final Long lastId, final Long totalElements) { + this.lastId = lastId; + this.totalElements = totalElements; + } + + public Long getLastId() { + return lastId; + } + + public Long getTotalElements() { + return totalElements; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java new file mode 100644 index 000000000..2b24e9207 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java @@ -0,0 +1,20 @@ +package com.funeat.recipe.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public class RecipeCommentCreateRequest { + + @NotBlank(message = "꿀조합 댓글을 확인해 주세요") + @Size(max = 200, message = "꿀조합 댓글은 최대 200자까지 입력 가능합니다") + private final String comment; + + public RecipeCommentCreateRequest(@JsonProperty("comment") final String comment) { + this.comment = comment; + } + + public String getComment() { + return comment; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java new file mode 100644 index 000000000..ad66d7811 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java @@ -0,0 +1,26 @@ +package com.funeat.recipe.dto; + +import com.funeat.member.domain.Member; + +public class RecipeCommentMemberResponse { + + private final String nickname; + private final String profileImage; + + private RecipeCommentMemberResponse(final String nickname, final String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } + + public static RecipeCommentMemberResponse toResponse(final Member member) { + return new RecipeCommentMemberResponse(member.getNickname(), member.getProfileImage()); + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java new file mode 100644 index 000000000..989e52bd5 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java @@ -0,0 +1,42 @@ +package com.funeat.recipe.dto; + +import com.funeat.comment.domain.Comment; +import java.time.LocalDateTime; + +public class RecipeCommentResponse { + + private final Long id; + private final String comment; + private final LocalDateTime createdAt; + private final RecipeCommentMemberResponse author; + + private RecipeCommentResponse(final Long id, final String comment, final LocalDateTime createdAt, + final RecipeCommentMemberResponse author) { + this.id = id; + this.comment = comment; + this.createdAt = createdAt; + this.author = author; + } + + public static RecipeCommentResponse toResponse(final Comment comment) { + final RecipeCommentMemberResponse author = RecipeCommentMemberResponse.toResponse(comment.getMember()); + + return new RecipeCommentResponse(comment.getId(), comment.getComment(), comment.getCreatedAt(), author); + } + + public Long getId() { + return id; + } + + public String getComment() { + return comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public RecipeCommentMemberResponse getAuthor() { + return author; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java new file mode 100644 index 000000000..7e7d6dc19 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java @@ -0,0 +1,34 @@ +package com.funeat.recipe.dto; + +import java.util.List; + +public class RecipeCommentsResponse { + + private final List comments; + private final boolean hasNext; + private final Long totalElements; + + private RecipeCommentsResponse(final List comments, final boolean hasNext, + final Long totalElements) { + this.comments = comments; + this.hasNext = hasNext; + this.totalElements = totalElements; + } + + public static RecipeCommentsResponse toResponse(final List comments, final boolean hasNext, + final Long totalElements) { + return new RecipeCommentsResponse(comments, hasNext, totalElements); + } + + public List getComments() { + return comments; + } + + public boolean getHasNext() { + return hasNext; + } + + public Long getTotalElements() { + return totalElements; + } +} 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 8406c1645..17eb1f1d6 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java @@ -5,6 +5,9 @@ import com.funeat.common.logging.Logging; import com.funeat.recipe.application.RecipeService; import com.funeat.recipe.dto.RankingRecipesResponse; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; @@ -19,6 +22,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -88,4 +92,22 @@ public ResponseEntity getSearchResults(@RequestPara return ResponseEntity.ok(response); } + + @PostMapping("/api/recipes/{recipeId}/comments") + public ResponseEntity writeComment(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @RequestBody @Valid final RecipeCommentCreateRequest request) { + final Long savedCommentId = recipeService.writeCommentOfRecipe(loginInfo.getId(), recipeId, request); + + return ResponseEntity.created(URI.create("/api/recipes/" + recipeId + "/" + savedCommentId)).build(); + } + + @GetMapping("/api/recipes/{recipeId}/comments") + public ResponseEntity getCommentsOfRecipe( + @AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long recipeId, + @ModelAttribute final RecipeCommentCondition condition) { + final RecipeCommentsResponse response = recipeService.getCommentsOfRecipe(recipeId, condition); + + 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 013c559cd..05602cc7f 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java @@ -3,6 +3,9 @@ import com.funeat.auth.dto.LoginInfo; import com.funeat.auth.util.AuthenticationPrincipal; import com.funeat.recipe.dto.RankingRecipesResponse; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; @@ -16,6 +19,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -80,4 +84,24 @@ ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo loginIn @GetMapping ResponseEntity getSearchResults(@RequestParam final String query, @PageableDefault final Pageable pageable); + + @Operation(summary = "꿀조합 댓글 작성", description = "꿀조합 상세에서 댓글을 작성한다.") + @ApiResponse( + responseCode = "201", + description = "꿀조합 댓글 작성 성공." + ) + @PostMapping("/api/recipes/{recipeId}/comments") + ResponseEntity writeComment(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @RequestBody final RecipeCommentCreateRequest request); + + @Operation(summary = "꿀조합 댓글 조회", description = "꿀조합 상세에서 댓글을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "꿀조합 댓글 조회 성공." + ) + @GetMapping("/api/recipes/{recipeId}/comments") + ResponseEntity getCommentsOfRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @ModelAttribute final RecipeCommentCondition condition); } diff --git a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java index 17c6d725b..128490663 100644 --- a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java @@ -1,5 +1,6 @@ package com.funeat.acceptance.common; +import com.funeat.comment.persistence.CommentRepository; import com.funeat.common.DataClearExtension; import com.funeat.member.domain.Member; import com.funeat.member.persistence.MemberRepository; @@ -68,6 +69,9 @@ public abstract class AcceptanceTest { @Autowired public RecipeFavoriteRepository recipeFavoriteRepository; + @Autowired + protected CommentRepository commentRepository; + @BeforeEach void setUp() { RestAssured.port = port; 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 af0b74801..bb388ea78 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java @@ -11,6 +11,8 @@ import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; 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.레시피_랭킹_조회_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_목록_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_상세_정보_요청; @@ -59,12 +61,14 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.funeat.acceptance.common.AcceptanceTest; -import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.dto.ProductRecipeDto; import com.funeat.recipe.dto.RankingRecipeDto; import com.funeat.recipe.dto.RecipeAuthorDto; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; @@ -532,6 +536,189 @@ class getRankingRecipes_성공_테스트 { } } + @Nested + class writeRecipeComment_성공_테스트 { + + @Test + void 꿀조합에_댓글을_작성할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트 1"); + + final var 응답 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 정상_생성); + 꿀조합_댓글_작성_결과를_검증한다(응답, 멤버2, 꿀조합_댓글); + } + } + + @Nested + class writeRecipeComment_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 꿀조합에_댓글을_작성할때_댓글이_비어있을시_예외가_발생한다(final String comment) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest(comment); + + final var 레시피_댓글_작성_요청 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(레시피_댓글_작성_요청, 잘못된_요청); + RESPONSE_CODE와_MESSAGE를_검증한다(레시피_댓글_작성_요청, REQUEST_VALID_ERROR_CODE.getCode(), + "꿀조합 댓글을 확인해 주세요. " + REQUEST_VALID_ERROR_CODE.getMessage()); + } + + @Test + void 꿀조합에_댓글을_작성할때_댓글이_200자_초과시_예외가_발생한다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("1" + "댓글입니다".repeat(40)); + + final var 응답 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 잘못된_요청); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REQUEST_VALID_ERROR_CODE.getCode(), + "꿀조합 댓글은 최대 200자까지 입력 가능합니다. " + REQUEST_VALID_ERROR_CODE.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + void 로그인_하지않은_사용자가_꿀조합_댓글_작성시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트 1"); + + final var 응답 = 레시피_댓글_작성_요청(cookie, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), + LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + } + + @Nested + class getRecipeComment_성공_테스트 { + + @Test + void 꿀조합에_댓글을_조회할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + + for (int i = 1; i <= 15; i++) { + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, + new RecipeCommentCreateRequest("테스트 코멘트" + i)); + } + + // when + final var 응답 = 레시피_댓글_조회_요청(로그인_쿠키_획득(멤버1), 작성된_꿀조합_아이디, + new RecipeCommentCondition(null, null)); + + // then + final var expectedSize = 10; + final var expectedHasNext = true; + + STATUS_CODE를_검증한다(응답, 정상_처리); + 레시피_댓글_조회_결과를_검증한다(응답, expectedSize, expectedHasNext); + } + + @Test + void 꿀조합에_댓글을_마지막_페이지를_조회할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + + final var totalElements = 15L; + final var lastId = 6L; + + for (int i = 1; i <= totalElements; i++) { + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, + new RecipeCommentCreateRequest("테스트 코멘트" + i)); + } + + // when + final var 응답 = 레시피_댓글_조회_요청(로그인_쿠키_획득(멤버1), 작성된_꿀조합_아이디, new RecipeCommentCondition(lastId, totalElements)); + + // then + final var expectedSize = 5; + final var expectedHasNext = false; + + STATUS_CODE를_검증한다(응답, 정상_처리); + 레시피_댓글_조회_결과를_검증한다(응답, expectedSize, expectedHasNext); + } + } + + @Nested + class getRecipeComment_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 로그인_하지않은_사용자가_꿀조합_댓글_조회시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트"); + + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // when + final var 응답 = 레시피_댓글_조회_요청(cookie, 작성된_꿀조합_아이디, + new RecipeCommentCondition(6L, 15L)); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), + LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + } + private void 레시피_목록_조회_결과를_검증한다(final ExtractableResponse response, final List recipeIds) { final var actual = response.jsonPath().getList("recipes", RecipeDto.class); @@ -590,4 +777,30 @@ class getRankingRecipes_성공_테스트 { assertThat(actual).extracting(SearchRecipeResultDto::getId) .containsExactlyElementsOf(recipeIds); } + + private Long 작성된_꿀조합_아이디_추출(final ExtractableResponse response) { + return Long.parseLong(response.header("Location").split("/")[3]); + } + + private void 꿀조합_댓글_작성_결과를_검증한다(final ExtractableResponse response, final Long memberId, + final RecipeCommentCreateRequest request) { + final var savedCommentId = Long.parseLong(response.header("Location").split("/")[4]); + + final var findComments = commentRepository.findAll(); + + assertSoftly(soft -> { + soft.assertThat(savedCommentId).isEqualTo(findComments.get(0).getId()); + soft.assertThat(memberId).isEqualTo(findComments.get(0).getMember().getId()); + soft.assertThat(request.getComment()).isEqualTo(findComments.get(0).getComment()); + }); + } + + private void 레시피_댓글_조회_결과를_검증한다(final ExtractableResponse response, final int expectedSize, + final boolean expectedHasNext) { + final var actualComments = response.jsonPath().getList("comments", RecipeCommentResponse.class); + final var actualHasNext = response.jsonPath().getBoolean("hasNext"); + + assertThat(actualComments).hasSize(expectedSize); + assertThat(actualHasNext).isEqualTo(expectedHasNext); + } } 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 4ff18dc2e..6ebc08ba5 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -4,6 +4,8 @@ import static com.funeat.fixture.RecipeFixture.레시피좋아요요청_생성; import static io.restassured.RestAssured.given; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeFavoriteRequest; import io.restassured.response.ExtractableResponse; @@ -90,4 +92,30 @@ public class RecipeSteps { .then() .extract(); } + + public static ExtractableResponse 레시피_댓글_작성_요청(final String loginCookie, + final Long recipeId, + final RecipeCommentCreateRequest request) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .body(request) + .when() + .post("/api/recipes/" + recipeId + "/comments") + .then() + .extract(); + } + + public static ExtractableResponse 레시피_댓글_조회_요청(final String loginCookie, final Long recipeId, + final RecipeCommentCondition condition) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .param("lastId", condition.getLastId()) + .param("totalElements", condition.getTotalElements()) + .when() + .get("/api/recipes/" + recipeId + "/comments") + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index 0d8ce8fd7..93dfd6292 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -1,7 +1,6 @@ package com.funeat.acceptance.review; import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; -import static com.funeat.acceptance.common.CommonSteps.LOCATION_헤더에서_ID_추출; import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_생성; import static io.restassured.RestAssured.given; diff --git a/backend/src/test/java/com/funeat/common/RepositoryTest.java b/backend/src/test/java/com/funeat/common/RepositoryTest.java index b438ca41b..6fdb2307b 100644 --- a/backend/src/test/java/com/funeat/common/RepositoryTest.java +++ b/backend/src/test/java/com/funeat/common/RepositoryTest.java @@ -4,8 +4,6 @@ import com.funeat.member.domain.favorite.RecipeFavorite; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.member.persistence.MemberRepository; -import com.funeat.member.persistence.ProductBookmarkRepository; -import com.funeat.member.persistence.RecipeBookMarkRepository; import com.funeat.member.persistence.RecipeFavoriteRepository; import com.funeat.member.persistence.ReviewFavoriteRepository; import com.funeat.product.domain.Category; @@ -42,12 +40,6 @@ public abstract class RepositoryTest { @Autowired protected MemberRepository memberRepository; - @Autowired - protected ProductBookmarkRepository productBookmarkRepository; - - @Autowired - protected RecipeBookMarkRepository recipeBookMarkRepository; - @Autowired protected RecipeFavoriteRepository recipeFavoriteRepository; diff --git a/backend/src/test/java/com/funeat/common/ServiceTest.java b/backend/src/test/java/com/funeat/common/ServiceTest.java index f8b58e5a2..6612223aa 100644 --- a/backend/src/test/java/com/funeat/common/ServiceTest.java +++ b/backend/src/test/java/com/funeat/common/ServiceTest.java @@ -1,12 +1,11 @@ package com.funeat.common; import com.funeat.auth.application.AuthService; +import com.funeat.comment.persistence.CommentRepository; import com.funeat.member.application.TestMemberService; import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.member.persistence.MemberRepository; -import com.funeat.member.persistence.ProductBookmarkRepository; -import com.funeat.member.persistence.RecipeBookMarkRepository; import com.funeat.member.persistence.RecipeFavoriteRepository; import com.funeat.member.persistence.ReviewFavoriteRepository; import com.funeat.product.application.CategoryService; @@ -48,12 +47,6 @@ public abstract class ServiceTest { @Autowired protected MemberRepository memberRepository; - @Autowired - protected ProductBookmarkRepository productBookmarkRepository; - - @Autowired - protected RecipeBookMarkRepository recipeBookMarkRepository; - @Autowired protected RecipeFavoriteRepository recipeFavoriteRepository; @@ -84,6 +77,9 @@ public abstract class ServiceTest { @Autowired protected TagRepository tagRepository; + @Autowired + protected CommentRepository commentRepository; + @Autowired protected AuthService authService; 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 fcd4e2f40..3fecf28bb 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import com.funeat.comment.domain.Comment; import com.funeat.common.ServiceTest; import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; @@ -35,17 +36,20 @@ import com.funeat.product.domain.CategoryType; import com.funeat.product.domain.Product; import com.funeat.product.exception.ProductException.ProductNotFoundException; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; @SuppressWarnings("NonAsciiCharacters") class RecipeServiceTest extends ServiceTest { @@ -317,7 +321,7 @@ class getSortingRecipes_성공_테스트 { } @Test - void 꿀조합을_최신순으로_정렬할_수_있다() { + void 꿀조합을_최신순으로_정렬할_수_있다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -333,7 +337,9 @@ class getSortingRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); + Thread.sleep(1000); final var recipe1_2 = 레시피_생성(member1, 3L); + Thread.sleep(1000); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); @@ -545,6 +551,190 @@ class likeRecipe_실패_테스트 { } } + @Nested + class writeCommentOfRecipe_성공_테스트 { + + @Test + void 꿀조합에_댓글을_작성할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + // when + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + final var savedCommentId = recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + + // then + final var result = commentRepository.findById(savedCommentId).get(); + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + assertThat(result).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(new Comment(savedRecipe, savedMember, request.getComment())); + } + } + + @Nested + class writeCommentOfRecipe_실패_테스트 { + + @Test + void 존재하지_않은_멤버가_꿀조합에_댓글을_작성하면_예외가_발생한다() { + // given + final var category = 카테고리_간편식사_생성(); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = new Member("author", "image.png", "1"); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var notExistMemberId = 999999999L; + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + + // when then + assertThatThrownBy(() -> recipeService.writeCommentOfRecipe(notExistMemberId, savedRecipeId, request)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 존재하지_않은_꿀조합에_댓글을_작성하면_예외가_발생한다() { + // given + final var memberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + final var notExistRecipeId = 999999999L; + + // when then + assertThatThrownBy(() -> recipeService.writeCommentOfRecipe(memberId, notExistRecipeId, request)) + .isInstanceOf(RecipeNotFoundException.class); + } + } + + @Nested + class getCommentsOfRecipe_성공_테스트 { + + @Test + void 꿀조합에_달린_댓글들을_커서페이징을_통해_조회할_수_있다_총_댓글_15개_중_첫페이지_댓글_10개조회() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + for (int i = 1; i <= 15; i++) { + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요" + i); + recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + } + + // when + final var result = recipeService.getCommentsOfRecipe(savedRecipeId, + new RecipeCommentCondition(null, null)); + + // + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + final var expectedCommentResponses = new ArrayList<>(); + for (int i = 0; i < result.getComments().size(); i++) { + expectedCommentResponses.add(RecipeCommentResponse.toResponse( + new Comment(savedRecipe, savedMember, "꿀조합 댓글이에요" + (15 - i)))); + } + + assertThat(result.getHasNext()).isTrue(); + assertThat(result.getTotalElements()).isEqualTo(15); + assertThat(result.getComments()).hasSize(10); + assertThat(result.getComments()).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(expectedCommentResponses); + } + + @Test + void 꿀조합에_달린_댓글들을_커서페이징을_통해_조회할_수_있다_총_댓글_15개_중_마지막페이지_댓글_5개조회() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + for (int i = 1; i <= 15; i++) { + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요" + i); + recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + } + + // when + final var result = recipeService.getCommentsOfRecipe(savedRecipeId, + new RecipeCommentCondition(6L, 15L)); + + // + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + final var expectedCommentResponses = new ArrayList<>(); + for (int i = 0; i < result.getComments().size(); i++) { + expectedCommentResponses.add(RecipeCommentResponse.toResponse( + new Comment(savedRecipe, savedMember, "꿀조합 댓글이에요" + (5 - i)))); + } + + assertThat(result.getHasNext()).isFalse(); + assertThat(result.getTotalElements()).isEqualTo(15); + assertThat(result.getComments()).hasSize(5); + assertThat(result.getComments()).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(expectedCommentResponses); + } + } + private void 해당멤버의_꿀조합과_페이징_결과를_검증한다(final MemberRecipesResponse actual, final List expectedRecipesDtos, final PageDto expectedPage) { assertSoftly(soft -> { 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 c7130d2ce..f52eae169 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -119,7 +119,7 @@ class findAllRecipes_성공_테스트 { } @Test - void 꿀조합을_최신순으로_정렬한다() { + void 꿀조합을_최신순으로_정렬한다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -135,7 +135,9 @@ class findAllRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); + Thread.sleep(1000); final var recipe1_2 = 레시피_생성(member1, 3L); + Thread.sleep(1000); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index e6ca43b22..597033063 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -441,7 +441,7 @@ class sortingReviews_성공_테스트 { } @Test - void 최신순으로_정렬을_할_수_있다() { + void 최신순으로_정렬을_할_수_있다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -455,7 +455,9 @@ class sortingReviews_성공_테스트 { final var productId = 단일_상품_저장(product); final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + Thread.sleep(1000); final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + Thread.sleep(1000); final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); 복수_리뷰_저장(review1, review2, review3); From f614edac8f80463212a87c0e18345a15a82b1b98 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 14:19:21 +0900 Subject: [PATCH 05/20] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=97=90=20?= =?UTF-8?q?credentials=20=EC=B6=94=EA=B0=80=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index 460b11e92..7bcbe60f5 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -13,6 +13,7 @@ const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const response = await recipeApi.get({ params: `/${recipeId}/comments`, queries: `?lastId=${lastId}&totalElements=${totalElements}`, + credentials: true, }); const data: CommentResponse = await response.json(); return data; From ff62d0ef6491c9025f8ef18305bceee1016a8af7 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 14:45:31 +0900 Subject: [PATCH 06/20] =?UTF-8?q?[FE]=20fix:=20totalElements=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20query=20string?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=20(#770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: totalElements가 없는 경우 query string에서 제외 * feat: credentials true 추가 --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index 7bcbe60f5..ec251f9b5 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -10,9 +10,11 @@ interface PageParam { const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const { lastId, totalElements } = pageParam; + const queries = `?lastId=${lastId}${totalElements !== null ? `&totalElements=${totalElements}` : ''}`; + const response = await recipeApi.get({ params: `/${recipeId}/comments`, - queries: `?lastId=${lastId}&totalElements=${totalElements}`, + queries: queries, credentials: true, }); const data: CommentResponse = await response.json(); From be83c6b0bf8d6d3c7b93689b9b375f0c105853ed Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 15:16:31 +0900 Subject: [PATCH 07/20] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=EC=9D=B4=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20(#7?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 댓글이 없는 경우 ui 처리 * feat: 댓글이 없는 경우 id를 읽어올 수 없는 문제 해결 --- frontend/src/components/Recipe/CommentList/CommentList.tsx | 3 ++- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx index 5a34feb95..093d5d93e 100644 --- a/frontend/src/components/Recipe/CommentList/CommentList.tsx +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -1,4 +1,4 @@ -import { Heading, Spacing } from '@fun-eat/design-system'; +import { Heading, Spacing, Text, theme } from '@fun-eat/design-system'; import { useRef } from 'react'; import CommentItem from '../CommentItem/CommentItem'; @@ -24,6 +24,7 @@ const CommentList = ({ recipeId }: CommentListProps) => { 댓글 ({comments.length}개) + {comments.length === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} {comments.map((comment) => ( ))} diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index ec251f9b5..dd870941b 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -27,7 +27,7 @@ const useInfiniteRecipeCommentQuery = (recipeId: number) => { ({ pageParam = { lastId: 0, totalElements: null } }) => fetchRecipeComments(pageParam, recipeId), { getNextPageParam: (prevResponse: CommentResponse) => { - const lastId = prevResponse.comments[prevResponse.comments.length - 1].id; + const lastId = prevResponse.comments.length ? prevResponse.comments[prevResponse.comments.length - 1].id : 0; const totalElements = prevResponse.totalElements; const lastCursor = { lastId: lastId, totalElements: totalElements }; return prevResponse.hasNext ? lastCursor : undefined; From 4cd0eaf3a986bc634f6ec4a21d9c768c3642f8af Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 15:44:29 +0900 Subject: [PATCH 08/20] =?UTF-8?q?[FE]=20feat:=20totalElements=EA=B0=80=20n?= =?UTF-8?q?ull=EC=9D=BC=20=EB=95=8C=20=EC=95=84=EC=98=88=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=B3=B4=EB=82=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index dd870941b..068520c8a 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -10,7 +10,7 @@ interface PageParam { const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const { lastId, totalElements } = pageParam; - const queries = `?lastId=${lastId}${totalElements !== null ? `&totalElements=${totalElements}` : ''}`; + const queries = totalElements === null ? '' : `?lastId=${lastId}&totalElements=${totalElements}`; const response = await recipeApi.get({ params: `/${recipeId}/comments`, From 41a1d75fbe2e392f082a27738605d0008916122c Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Mon, 16 Oct 2023 17:55:14 +0900 Subject: [PATCH 09/20] =?UTF-8?q?[BE]=20feat:=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=8F=99=EC=A0=81=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20API=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 좋아요 기준 내림차순 리뷰 목록 조회 쿼리 개선 * test: 좋아요 기준 내림차순 테스트 재생성 * feat: 최신순으로 리뷰 목록 조회 쿼리 개선 * test: 최신순 리뷰 목록 테스트 재생성 * feat: 평점순 정렬 리뷰 목록 조회 쿼리 개선 * test: 평점순 정렬 리뷰 목록 테스트 재생성 * feat: 정렬 조건에 따라 리뷰 목록을 반환하는 기능 추가 * feat: 정렬 기능 추가 * refactor: 테스트 추가 및 conflict 해결 * fix: 생성자가 여러개라 jackson이 json으로 변환하지 못하는 현상 수정 * fix: 2차 정렬 기준이 ID 기준 내림차순으로 수정 * fix: 좋아요를 누른 사람이 여러명이면 그 개수만큼 같은 리뷰를 반환하던 쿼리문 수정 * test: 프로덕션 코드 수정으로 인한 테스트 코드 수정 * refactor: 정렬 조건에 맞게 리뷰 목록 생성 * refactor: 주석 삭제 * fix: 데이터를 11개가 아니라 10개를 반환하도록 수정 * refactor: 리뷰 랭킹에서 좋아요가 같으면 최신 리뷰순으로 정렬하기 추가 * temp: Criteria API + Specification으로 동적 쿼리 기능 구현 리팩터링 진행중 * refactor: SortSpec -> ReviewSortSpec 네이밍 변경 * refactor: 다른 곳에서 객체를 생성할 수 없도록 수정 * refactor: SortingReviewDto Wrapper 타입으로 변경, 유저 좋아요 데이터 기본값 변경 * refactor: static 삭제 * refactor: SortingReviewDto의 멤버 변수에 final 추가 * refactor: 동적 쿼리 이전의 리뷰 목록 정렬 코드 삭제 * refactor: 정렬 조건에 없는 필드 이름이 아니면 예외를 반환하도록 수정 * refactor: 클래스 네이밍 변경 LongTypeSortSpec -> LongTypeReviewSortSpec * refactor: 정렬만 하는 서비스 클래스 분리, Tag까지 가져올 수 있도록 수정 * fix: 충돌 해결 * test: Thread.sleep()을 1초가 아닌 0.1초로 수정 * refactor: Wrapper -> Primitive 타입으로 변경 * refactor: is@@@ -> get@@@로 변경 * refactor: 다음 페이지가 존재하는지는 hasNext로 통일 * refactor: 리뷰 정렬 클래스 삭제, 상수 이름을 목적에 따라 분리 * refactor: 에러 코드 네이밍 수정 * refactor: exception 이름도 같이 수정 --- .../review/application/ReviewService.java | 78 +++++++-- .../funeat/review/dto/SortingReviewDto.java | 51 +++--- .../dto/SortingReviewDtoWithoutTag.java | 84 +++++++++ .../review/dto/SortingReviewRequest.java | 27 +++ .../review/dto/SortingReviewsResponse.java | 19 +-- .../review/exception/ReviewErrorCode.java | 3 +- .../review/exception/ReviewException.java | 6 + .../persistence/ReviewCustomRepository.java | 14 ++ .../review/persistence/ReviewRepository.java | 8 +- .../persistence/ReviewRepositoryImpl.java | 91 ++++++++++ .../presentation/ReviewApiController.java | 6 +- .../review/presentation/ReviewController.java | 4 +- .../specification/LongTypeReviewSortSpec.java | 27 +++ .../SortingReviewSpecification.java | 161 ++++++++++++++++++ .../funeat/tag/persistence/TagRepository.java | 8 + .../funeat/acceptance/common/CommonSteps.java | 6 + .../review/ReviewAcceptanceTest.java | 58 +++---- .../funeat/acceptance/review/ReviewSteps.java | 4 +- .../java/com/funeat/fixture/PageFixture.java | 26 +++ .../com/funeat/fixture/ReviewFixture.java | 30 ++++ .../recipe/application/RecipeServiceTest.java | 4 +- .../persistence/RecipeRepositoryTest.java | 4 +- .../review/application/ReviewServiceTest.java | 154 +++++++---------- .../persistence/ReviewRepositoryTest.java | 39 +---- 24 files changed, 677 insertions(+), 235 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java create mode 100644 backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java create mode 100644 backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java create mode 100644 backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java create mode 100644 backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java create mode 100644 backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 027f8d5ae..320bc13eb 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -27,11 +27,14 @@ import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.review.specification.SortingReviewSpecification; import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; import java.util.List; @@ -42,6 +45,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -50,9 +54,11 @@ @Transactional(readOnly = true) public class ReviewService { - private static final int TOP = 0; + private static final int FIRST_PAGE = 0; + private static final int START_INDEX = 0; private static final int ONE = 1; private static final String EMPTY_URL = ""; + private static final int REVIEW_PAGE_SIZE = 10; private final ReviewRepository reviewRepository; private final TagRepository tagRepository; @@ -121,8 +127,7 @@ public void likeReview(final Long reviewId, final Long memberId, final ReviewFav private ReviewFavorite saveReviewFavorite(final Member member, final Review review, final Boolean favorite) { try { - final ReviewFavorite reviewFavorite = ReviewFavorite.create(member, review, - favorite); + final ReviewFavorite reviewFavorite = ReviewFavorite.create(member, review, favorite); return reviewFavoriteRepository.save(reviewFavorite); } catch (final DataIntegrityViolationException e) { throw new MemberDuplicateFavoriteException(MEMBER_DUPLICATE_FAVORITE, member.getId()); @@ -134,33 +139,76 @@ public void updateProductImage(final Long productId) { final Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final PageRequest pageRequest = PageRequest.of(TOP, ONE); + final PageRequest pageRequest = PageRequest.of(FIRST_PAGE, ONE); + final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); if (!topFavoriteReview.isEmpty()) { - final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage(); + final String topFavoriteReviewImage = topFavoriteReview.get(START_INDEX).getImage(); product.updateImage(topFavoriteReviewImage); } } - public SortingReviewsResponse sortingReviews(final Long productId, final Pageable pageable, final Long memberId) { - final Member member = memberRepository.findById(memberId) + public SortingReviewsResponse sortingReviews(final Long productId, final Long memberId, + final SortingReviewRequest request) { + final Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); - - final Product product = productRepository.findById(productId) + final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Page reviewPage = reviewRepository.findReviewsByProduct(pageable, product); + final List sortingReviews = getSortingReviews(findMember, findProduct, request); + final int resultSize = getResultSize(sortingReviews); + + final List resizeSortingReviews = sortingReviews.subList(START_INDEX, resultSize); + final Boolean hasNext = hasNextPage(sortingReviews); + + return SortingReviewsResponse.toResponse(resizeSortingReviews, hasNext); + } + + private List getSortingReviews(final Member member, final Product product, + final SortingReviewRequest request) { + final Long lastReviewId = request.getLastReviewId(); + final String sortOption = request.getSort(); + + final Specification specification = getSortingSpecification(product, sortOption, lastReviewId); + final List sortingReviewDtoWithoutTags = reviewRepository.getSortingReview(member, + specification, sortOption); - final PageDto pageDto = PageDto.toDto(reviewPage); - final List reviewDtos = reviewPage.stream() - .map(review -> SortingReviewDto.toDto(review, member)) + return addTagsToSortingReviews(sortingReviewDtoWithoutTags); + } + + private List addTagsToSortingReviews( + final List sortingReviewDtoWithoutTags) { + return sortingReviewDtoWithoutTags.stream() + .map(reviewDto -> SortingReviewDto.toDto(reviewDto, + tagRepository.findTagsByReviewId(reviewDto.getId()))) .collect(Collectors.toList()); + } + + private Specification getSortingSpecification(final Product product, final String sortOption, + final Long lastReviewId) { + if (lastReviewId == FIRST_PAGE) { + return SortingReviewSpecification.sortingFirstPageBy(product); + } + + final Review lastReview = reviewRepository.findById(lastReviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, lastReviewId)); + + return SortingReviewSpecification.sortingBy(product, sortOption, lastReview); + } + + private int getResultSize(final List sortingReviews) { + if (sortingReviews.size() <= REVIEW_PAGE_SIZE) { + return sortingReviews.size(); + } + return REVIEW_PAGE_SIZE; + } - return SortingReviewsResponse.toResponse(pageDto, reviewDtos); + private Boolean hasNextPage(final List sortingReviews) { + return sortingReviews.size() > REVIEW_PAGE_SIZE; } public RankingReviewsResponse getTopReviews() { - final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); final List dtos = rankingReviews.stream() .map(RankingReviewDto::toDto) diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java index 7254dd6c1..1231d0058 100644 --- a/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java @@ -1,12 +1,16 @@ package com.funeat.review.dto; +import com.fasterxml.jackson.annotation.JsonCreator; import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.domain.Tag; import com.funeat.tag.dto.TagDto; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; public class SortingReviewDto { @@ -23,6 +27,7 @@ public class SortingReviewDto { private final boolean favorite; private final LocalDateTime createdAt; + @JsonCreator public SortingReviewDto(final Long id, final String userName, final String profileImage, final String image, final Long rating, final List tags, final String content, final boolean rebuy, final Long favoriteCount, final boolean favorite, @@ -40,37 +45,23 @@ public SortingReviewDto(final Long id, final String userName, final String profi this.createdAt = createdAt; } - public static SortingReviewDto toDto(final Review review, final Member member) { - return new SortingReviewDto( - review.getId(), - review.getMember().getNickname(), - review.getMember().getProfileImage(), - review.getImage(), - review.getRating(), - findTagDtos(review), - review.getContent(), - review.getReBuy(), - review.getFavoriteCount(), - findReviewFavoriteChecked(review, member), - review.getCreatedAt() - ); - } - - private static List findTagDtos(final Review review) { - return review.getReviewTags().stream() - .map(ReviewTag::getTag) + public static SortingReviewDto toDto(final SortingReviewDtoWithoutTag sortingReviewDto, final List tags) { + final List tagDtos = tags.stream() .map(TagDto::toDto) .collect(Collectors.toList()); - } - private static boolean findReviewFavoriteChecked(final Review review, final Member member) { - return review.getReviewFavorites() - .stream() - .filter(reviewFavorite -> reviewFavorite.getReview().equals(review)) - .filter(reviewFavorite -> reviewFavorite.getMember().equals(member)) - .findFirst() - .map(ReviewFavorite::getFavorite) - .orElse(false); + return new SortingReviewDto( + sortingReviewDto.getId(), + sortingReviewDto.getUserName(), + sortingReviewDto.getProfileImage(), + sortingReviewDto.getImage(), + sortingReviewDto.getRating(), + tagDtos, + sortingReviewDto.getContent(), + sortingReviewDto.getRebuy(), + sortingReviewDto.getFavoriteCount(), + sortingReviewDto.getFavorite(), + sortingReviewDto.getCreatedAt()); } public Long getId() { @@ -101,7 +92,7 @@ public String getContent() { return content; } - public boolean isRebuy() { + public boolean getRebuy() { return rebuy; } @@ -109,7 +100,7 @@ public Long getFavoriteCount() { return favoriteCount; } - public boolean isFavorite() { + public boolean getFavorite() { return favorite; } diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java new file mode 100644 index 000000000..287750e7f --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java @@ -0,0 +1,84 @@ +package com.funeat.review.dto; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class SortingReviewDtoWithoutTag { + + private final Long id; + private final String userName; + private final String profileImage; + private final String image; + private final Long rating; + private final String content; + private final boolean rebuy; + private final Long favoriteCount; + private final boolean favorite; + private final LocalDateTime createdAt; + + public SortingReviewDtoWithoutTag(final Long id, final String userName, final String profileImage, + final String image, final Long rating, + final String content, final boolean rebuy, final Long favoriteCount, + final Boolean favorite, + final LocalDateTime createdAt) { + final Boolean isFavorite = checkingFavorite(favorite); + + this.id = id; + this.userName = userName; + this.profileImage = profileImage; + this.image = image; + this.rating = rating; + this.content = content; + this.rebuy = rebuy; + this.favoriteCount = favoriteCount; + this.favorite = isFavorite; + this.createdAt = createdAt; + } + + private static Boolean checkingFavorite(final Boolean favorite) { + if (Objects.isNull(favorite)) { + return Boolean.FALSE; + } + return Boolean.TRUE; + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getProfileImage() { + return profileImage; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public String getContent() { + return content; + } + + public boolean getRebuy() { + return rebuy; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public boolean getFavorite() { + return favorite; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java new file mode 100644 index 000000000..b6bdeb1eb --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java @@ -0,0 +1,27 @@ +package com.funeat.review.dto; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.PositiveOrZero; + +public class SortingReviewRequest { + + @NotNull(message = "정렬 조건을 확인해주세요") + private String sort; + + @NotNull(message = "마지막으로 조회한 리뷰 ID를 확인해주세요") + @PositiveOrZero(message = "마지막으로 조회한 ID는 0 이상이어야 합니다. (처음 조회하면 0)") + private Long lastReviewId; + + public SortingReviewRequest(final String sort, final Long lastReviewId) { + this.sort = sort; + this.lastReviewId = lastReviewId; + } + + public String getSort() { + return sort; + } + + public Long getLastReviewId() { + return lastReviewId; + } +} diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java index caf1ea155..1dc082fe0 100644 --- a/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java @@ -1,27 +1,26 @@ package com.funeat.review.dto; -import com.funeat.common.dto.PageDto; import java.util.List; public class SortingReviewsResponse { - private final PageDto page; private final List reviews; + private final Boolean hasNext; - public SortingReviewsResponse(final PageDto page, final List reviews) { - this.page = page; + public SortingReviewsResponse(final List reviews, final Boolean hasNext) { this.reviews = reviews; + this.hasNext = hasNext; } - public static SortingReviewsResponse toResponse(final PageDto page, final List reviews) { - return new SortingReviewsResponse(page, reviews); - } - - public PageDto getPage() { - return page; + public static SortingReviewsResponse toResponse(final List reviews, final Boolean hasNextReview) { + return new SortingReviewsResponse(reviews, hasNextReview); } public List getReviews() { return reviews; } + + public Boolean getHasNext() { + return hasNext; + } } diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java index 05331dac9..2f5fb5c64 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java @@ -5,7 +5,8 @@ public enum ReviewErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"), - NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002") + NOT_SUPPORTED_REVIEW_SORTING_CONDITION(HttpStatus.BAD_REQUEST, "존재하지 않는 정렬 옵션입니다. 정렬 옵션을 확인하세요.", "3002"), + NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3003") ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewException.java b/backend/src/main/java/com/funeat/review/exception/ReviewException.java index a961f3301..85fd3f666 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewException.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewException.java @@ -16,6 +16,12 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie } } + public static class NotSupportedReviewSortingConditionException extends ReviewException { + public NotSupportedReviewSortingConditionException(final ReviewErrorCode errorCode, final String sortFieldName) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), sortFieldName)); + } + } + public static class NotAuthorOfReviewException extends ReviewException { public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) { super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId)); diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java new file mode 100644 index 000000000..e2dd79992 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java @@ -0,0 +1,14 @@ +package com.funeat.review.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +public interface ReviewCustomRepository { + + List getSortingReview(final Member loginMember, + final Specification specification, + final String sortField); +} diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java index f5ed0058f..1cf889b0b 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -5,6 +5,8 @@ import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDto; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -14,11 +16,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ReviewRepository extends JpaRepository { +public interface ReviewRepository extends JpaRepository, ReviewCustomRepository { - Page findReviewsByProduct(final Pageable pageable, final Product product); - - List findTop3ByOrderByFavoriteCountDesc(); + List findTop3ByOrderByFavoriteCountDescIdDesc(); Long countByProduct(final Product product); diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java new file mode 100644 index 000000000..ae47f7127 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java @@ -0,0 +1,91 @@ +package com.funeat.review.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import java.util.List; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; +import javax.persistence.criteria.CompoundSelection; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Join; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Repository; + +@Repository +public class ReviewRepositoryImpl implements ReviewCustomRepository { + + @PersistenceContext + private EntityManager em; + + @Override + public List getSortingReview(final Member loginMember, + final Specification specification, + final String sortOption) { + final CriteriaBuilder cb = em.getCriteriaBuilder(); + final CriteriaQuery cq = cb.createQuery(SortingReviewDtoWithoutTag.class); + final Root root = cq.from(Review.class); + + // sortField, sortOrder + final String[] sortOptionSplit = sortOption.split(","); + final String sortField = sortOptionSplit[0]; + final String sortOrder = sortOptionSplit[1]; + + // join + final Join joinMember = root.join("member", JoinType.INNER); + + // left join + final Join leftJoinReviewFavorite = root.join("reviewFavorites", JoinType.LEFT); + final Predicate condition = cb.equal(leftJoinReviewFavorite.get("member"), loginMember); + leftJoinReviewFavorite.on(condition); + + // select - from - where - order by + cq.select(getConstruct(root, cb, joinMember, leftJoinReviewFavorite)) + .where(specification.toPredicate(root, cq, cb)) + .orderBy(getOrderBy(root, cb, sortField, sortOrder)); + + // limit + final TypedQuery query = em.createQuery(cq); + query.setMaxResults(11); + + // result + return query.getResultList(); + } + + private CompoundSelection getConstruct(final Root root, + final CriteriaBuilder cb, + final Join joinMember, + final Join leftJoinReviewFavorite) { + + return cb.construct(SortingReviewDtoWithoutTag.class, + root.get("id"), + joinMember.get("nickname"), + joinMember.get("profileImage"), + root.get("image"), + root.get("rating"), + root.get("content"), + root.get("reBuy"), + root.get("favoriteCount"), + leftJoinReviewFavorite.get("favorite"), + root.get("createdAt")); + } + + private List getOrderBy(final Root root, + final CriteriaBuilder cb, + final String fieldName, + final String sortOption) { + if ("ASC".equalsIgnoreCase(sortOption)) { + final Order order = cb.asc(root.get(fieldName)); + return List.of(order, cb.desc(root.get("id"))); + } + final Order order = cb.desc(root.get(fieldName)); + return List.of(order, cb.desc(root.get("id"))); + } +} 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 ba094cb14..cb68d6e6b 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -8,6 +8,7 @@ import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import java.net.URI; import java.util.Objects; @@ -19,6 +20,7 @@ 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; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -63,8 +65,8 @@ public ResponseEntity toggleLikeReview(@PathVariable final Long productId, @GetMapping("/api/products/{productId}/reviews") public ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long productId, - @PageableDefault final Pageable pageable) { - final SortingReviewsResponse response = reviewService.sortingReviews(productId, pageable, loginInfo.getId()); + @ModelAttribute final SortingReviewRequest request) { + final SortingReviewsResponse response = reviewService.sortingReviews(productId, loginInfo.getId(), request); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 2e3d52459..0584134b5 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -6,6 +6,7 @@ import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -16,6 +17,7 @@ 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; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -56,7 +58,7 @@ ResponseEntity toggleLikeReview(@PathVariable final Long productId, @GetMapping ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long productId, - @PageableDefault final Pageable pageable); + @ModelAttribute final SortingReviewRequest request); @Operation(summary = "리뷰 랭킹 Top3 조회", description = "리뷰 랭킹 Top3 조회한다.") @ApiResponse( diff --git a/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java b/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java new file mode 100644 index 000000000..23914e003 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java @@ -0,0 +1,27 @@ +package com.funeat.review.specification; + +import com.funeat.review.domain.Review; +import java.util.Arrays; +import java.util.function.Function; + +public enum LongTypeReviewSortSpec { + + FAVORITE_COUNT("favoriteCount", Review::getFavoriteCount), + RATING("rating", Review::getRating); + + private final String fieldName; + private final Function function; + + LongTypeReviewSortSpec(final String fieldName, final Function function) { + this.fieldName = fieldName; + this.function = function; + } + + public static Long find(final String fieldName, final Review lastReview) { + return Arrays.stream(LongTypeReviewSortSpec.values()) + .filter(reviewSortSpec -> reviewSortSpec.fieldName.equals(fieldName)) + .findFirst() + .orElseThrow(IllegalArgumentException::new) + .function.apply(lastReview); + } +} diff --git a/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java b/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java new file mode 100644 index 000000000..781032017 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java @@ -0,0 +1,161 @@ +package com.funeat.review.specification; + +import static com.funeat.review.exception.ReviewErrorCode.NOT_SUPPORTED_REVIEW_SORTING_CONDITION; + +import com.funeat.product.domain.Product; +import com.funeat.review.domain.Review; +import com.funeat.review.exception.ReviewException.NotSupportedReviewSortingConditionException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; + +public final class SortingReviewSpecification { + + private static final List LOCALDATETIME_TYPE_INCLUDE = List.of("createdAt"); + private static final List LONG_TYPE_INCLUDE = List.of("favoriteCount", "rating"); + private static final String DELIMITER = ","; + private static final String PRODUCT = "product"; + private static final String ID = "id"; + private static final String ASC = "ASC"; + + private SortingReviewSpecification() { + } + + public static Specification sortingFirstPageBy(final Product product) { + return (root, query, criteriaBuilder) -> Specification + .where(equalsProduct(product)) + .toPredicate(root, query, criteriaBuilder); + } + + public static Specification sortingBy(final Product product, final String sortOption, + final Review lastReview) { + return (root, query, criteriaBuilder) -> { + final String[] sortFieldSplit = sortOption.split(DELIMITER); + final String field = sortFieldSplit[0]; + final String sort = sortFieldSplit[1]; + + return Specification + .where((equalsProduct(product).and(equals(field, lastReview)).and(lessThanLastReviewId(lastReview))) + .or(equalsProduct(product).and(lessOrGreaterThan(field, sort, lastReview)))) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification equalsProduct(final Product product) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(product)) { + return null; + } + + final Path productPath = root.get(PRODUCT); + + return criteriaBuilder.equal(productPath, product); + }; + } + + private static Specification lessThanLastReviewId(final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(lastReview)) { + return null; + } + + final Path reviewPath = root.get(ID); + + return criteriaBuilder.lessThan(reviewPath, lastReview.getId()); + }; + } + + private static Specification equals(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkEquals(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static Predicate checkEquals(final String fieldName, + final Review lastReview, + final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.equal(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.equal(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } + + private static Specification lessOrGreaterThan(final String field, final String sort, + final Review lastReview) { + if (ASC.equalsIgnoreCase(sort)) { + return greaterThan(field, lastReview); + } + return lessThan(field, lastReview); + } + + private static Specification greaterThan(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkGreaterThan(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static Predicate checkGreaterThan(final String fieldName, final Review lastReview, final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.greaterThan(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.greaterThan(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } + + private static Specification lessThan(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkLessThan(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static boolean validateNull(final String fieldName, final Review lastReview) { + return Objects.isNull(fieldName) || Objects.isNull(lastReview); + } + + private static Predicate checkLessThan(final String fieldName, final Review lastReview, final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.lessThan(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.lessThan(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } +} diff --git a/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java index b74e0197c..9ad319f7a 100644 --- a/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java +++ b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java @@ -4,10 +4,18 @@ import com.funeat.tag.domain.TagType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TagRepository extends JpaRepository { List findTagsByIdIn(final List tagIds); List findTagsByTagType(final TagType tagType); + + @Query("SELECT t " + + "FROM ReviewTag rt " + + "JOIN rt.tag t " + + "WHERE rt.review.id = :reviewId") + List findTagsByReviewId(@Param("reviewId") final Long reviewId); } diff --git a/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java index 32dcb85e2..af33aa14f 100644 --- a/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java @@ -74,4 +74,10 @@ public class CommonSteps { assertThat(actual).usingRecursiveComparison() .isEqualTo(expected); } + + public static void 다음_데이터가_있는지_검증한다(final ExtractableResponse response, final boolean expected) { + final var actual = response.jsonPath().getBoolean("hasNext"); + + assertThat(actual).isEqualTo(expected); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java index b1c7218db..76e4f48a5 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java @@ -2,6 +2,7 @@ import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.다음_데이터가_있는지_검증한다; import static com.funeat.acceptance.common.CommonSteps.사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.인증되지_않음; import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; @@ -9,7 +10,6 @@ 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.common.CommonSteps.페이지를_검증한다; import static com.funeat.acceptance.product.ProductSteps.상품_상세_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_랭킹_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_작성_요청; @@ -29,13 +29,7 @@ import static com.funeat.fixture.MemberFixture.멤버2; import static com.funeat.fixture.MemberFixture.멤버3; import static com.funeat.fixture.PageFixture.FIRST_PAGE; -import static com.funeat.fixture.PageFixture.PAGE_SIZE; -import static com.funeat.fixture.PageFixture.마지막페이지O; -import static com.funeat.fixture.PageFixture.응답_페이지_생성; import static com.funeat.fixture.PageFixture.좋아요수_내림차순; -import static com.funeat.fixture.PageFixture.첫페이지O; -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.PageFixture.평점_오름차순; @@ -43,6 +37,7 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.존재하지_않는_상품; +import static com.funeat.fixture.ReviewFixture.다음_데이터_존재X; import static com.funeat.fixture.ReviewFixture.리뷰; import static com.funeat.fixture.ReviewFixture.리뷰1; import static com.funeat.fixture.ReviewFixture.리뷰2; @@ -56,6 +51,7 @@ import static com.funeat.fixture.ReviewFixture.존재하지_않는_리뷰; import static com.funeat.fixture.ReviewFixture.좋아요O; import static com.funeat.fixture.ReviewFixture.좋아요X; +import static com.funeat.fixture.ReviewFixture.첫_목록을_가져옴; import static com.funeat.fixture.ScoreFixture.점수_1점; import static com.funeat.fixture.ScoreFixture.점수_2점; import static com.funeat.fixture.ScoreFixture.점수_3점; @@ -386,14 +382,12 @@ class 좋아요_기준_내림차순으로_리뷰_목록_조회 { 여러명이_리뷰_좋아요_요청(List.of(멤버1), 상품, 리뷰3, 좋아요O); 여러명이_리뷰_좋아요_요청(List.of(멤버2, 멤버3), 상품, 리뷰2, 좋아요O); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰2, 리뷰3, 리뷰1)); } @@ -409,14 +403,12 @@ class 좋아요_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -436,14 +428,12 @@ class 평점_기준_오름차순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_오름차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_오름차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰1, 리뷰3, 리뷰2)); } @@ -459,14 +449,12 @@ class 평점_기준_오름차순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_오름차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_오름차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -486,14 +474,12 @@ class 평점_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰2, 리뷰3, 리뷰1)); } @@ -509,14 +495,12 @@ class 평점_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -536,14 +520,12 @@ class 최신순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 최신순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 최신순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -564,7 +546,7 @@ class getSortingReviews_실패_테스트 { 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_2점, List.of(태그))); // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(cookie, 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(cookie, 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 인증되지_않음); @@ -574,8 +556,8 @@ class getSortingReviews_실패_테스트 { @Test void 존재하지_않는_상품의_리뷰_목록을_조회시_예외가_발생한다() { - // given & when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_상품, 좋아요수_내림차순, FIRST_PAGE); + // given && when + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 찾을수_없음); diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index 93dfd6292..ae4f81c16 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -55,14 +55,16 @@ public class ReviewSteps { } public static ExtractableResponse 정렬된_리뷰_목록_조회_요청(final String loginCookie, final Long productId, + final Long lastReviewId, final String sort, final Long page) { return given() .cookie("JSESSIONID", loginCookie) .queryParam("sort", sort) .queryParam("page", page) + .queryParam("lastReviewId", lastReviewId).log().all() .when() .get("/api/products/{product_id}/reviews", productId) - .then() + .then().log().all() .extract(); } diff --git a/backend/src/test/java/com/funeat/fixture/PageFixture.java b/backend/src/test/java/com/funeat/fixture/PageFixture.java index afae2d14a..773658f48 100644 --- a/backend/src/test/java/com/funeat/fixture/PageFixture.java +++ b/backend/src/test/java/com/funeat/fixture/PageFixture.java @@ -2,6 +2,7 @@ import com.funeat.common.dto.PageDto; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -18,6 +19,7 @@ public class PageFixture { public static final String 평점_내림차순 = "rating,desc"; public static final String 과거순 = "createdAt,asc"; public static final String 최신순 = "createdAt,desc"; + public static final String 아이디_내림차순 = "id,desc"; public static final Long PAGE_SIZE = 10L; public static final Long FIRST_PAGE = 0L; @@ -45,6 +47,30 @@ public class PageFixture { return new PageDto(totalDataCount, totalPages, firstPage, lastPage, requestPage, requestSize); } + public static Pageable 페이지요청_좋아요_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "favoriteCount"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_최신순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "createdAt"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_평점_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.ASC, "rating"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_평점_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "rating"); + + return PageRequest.of(page, size, sort); + } + public static Long 총_데이터_개수(final Long count) { return count; } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index 3ae53a3c7..d68a09b6b 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -1,10 +1,16 @@ package com.funeat.fixture; +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 com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import java.util.List; @SuppressWarnings("NonAsciiCharacters") @@ -21,6 +27,10 @@ public class ReviewFixture { public static final boolean 재구매O = true; public static final boolean 재구매X = false; + public static final Long 첫_목록을_가져옴 = 0L; + public static final boolean 다음_데이터_존재O = true; + public static final boolean 다음_데이터_존재X = false; + public static Review 리뷰_이미지test1_평점1점_재구매O_생성(final Member member, final Product product, final Long count) { return new Review(member, product, "test1", 1L, "test", true, count); } @@ -81,4 +91,24 @@ public class ReviewFixture { public static ReviewFavoriteRequest 리뷰좋아요요청_생성(final Boolean favorite) { return new ReviewFavoriteRequest(favorite); } + + public static SortingReviewRequest 리뷰정렬요청_좋아요수_내림차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(좋아요수_내림차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_최신순_생성(final Long lastReviewId) { + return new SortingReviewRequest(최신순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_평점_오름차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(평점_오름차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_평점_내림차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(평점_내림차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_존재하지않는정렬_생성() { + return new SortingReviewRequest("test,test", 1L); + } } 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 3fecf28bb..c0f68e789 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -337,9 +337,9 @@ class getSortingRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_2 = 레시피_생성(member1, 3L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); 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 f52eae169..494f7b215 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -135,9 +135,9 @@ class findAllRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_2 = 레시피_생성(member1, 3L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 597033063..2e7c82b43 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -4,13 +4,8 @@ import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; -import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -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.PageFixture.평점_내림차순; -import static com.funeat.fixture.PageFixture.평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; @@ -24,6 +19,10 @@ import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_좋아요수_내림차순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_최신순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_평점_내림차순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_평점_오름차순_생성; import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_생성; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; @@ -341,44 +340,36 @@ class sortingReviews_성공_테스트 { @Test void 좋아요_기준으로_내림차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); - final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 좋아요수_내림차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_좋아요수_내림차순_생성(0L); - final var expected = Stream.of(review1, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review1.getId(), review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 평점_기준으로_오름차순_정렬을_할_수_있다() { + void 최신순으로_정렬을_할_수_있다() throws InterruptedException { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -386,33 +377,30 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + Thread.sleep(100); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + Thread.sleep(100); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 평점_오름차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_최신순_생성(3L); - final var expected = Stream.of(review1, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review2.getId(), review1.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 평점_기준으로_내림차순_정렬을_할_수_있다() { + void 평점_기준으로_오름차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -420,33 +408,28 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 평점_내림차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_오름차순_생성(0L); - final var expected = Stream.of(review2, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review1.getId(), review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 최신순으로_정렬을_할_수_있다() throws InterruptedException { + void 평점_기준으로_내림차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -454,26 +437,21 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - Thread.sleep(1000); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - Thread.sleep(1000); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 5L); + final var review2 = 리뷰_이미지test2_평점2점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 13L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 최신순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); - final var expected = Stream.of(review3, review2) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } } @@ -483,10 +461,8 @@ class sortingReviews_실패_테스트 { @Test void 존재하지_않는_멤버가_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 3L; final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -494,26 +470,23 @@ class sortingReviews_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_기본_생성(0, 2); - final var wrongMemberId = member1.getId() + 3L; + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); // when & then - assertThatThrownBy(() -> reviewService.sortingReviews(productId, page, wrongMemberId)) + assertThatThrownBy(() -> reviewService.sortingReviews(productId, wrongMemberId, request)) .isInstanceOf(MemberNotFoundException.class); } @Test void 멤버가_존재하지_않는_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -521,16 +494,15 @@ class sortingReviews_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var wrongProductId = 단일_상품_저장(product) + 1L; - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_기본_생성(0, 2); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); // when & then - assertThatThrownBy(() -> reviewService.sortingReviews(wrongProductId, page, member1Id)) + assertThatThrownBy(() -> reviewService.sortingReviews(wrongProductId, memberId, request)) .isInstanceOf(ProductNotFoundException.class); } } diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java index d0198db1a..ebac6adac 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -5,8 +5,6 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -import static com.funeat.fixture.PageFixture.좋아요수_내림차순; -import static com.funeat.fixture.PageFixture.페이지요청_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; @@ -66,41 +64,6 @@ class countByProduct_성공_테스트 { } } - @Nested - class findReviewsByProduct_성공_테스트 { - - @Test - void 특정_상품에_대한_좋아요_기준_내림차순으로_정렬한다() { - // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - 단일_상품_저장(product); - - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); - 복수_리뷰_저장(review1, review2, review3); - - final var page = 페이지요청_생성(0, 2, 좋아요수_내림차순); - - final var expected = List.of(review1, review3); - - // when - final var actual = reviewRepository.findReviewsByProduct(page, product).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { @@ -129,7 +92,7 @@ class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { final var expected = List.of(review1_2, review2_2, review1_3); // when - final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); // then assertThat(actual).usingRecursiveComparison() From 0c55b8ab173ea0ecd30e0ccaa0f519356f329412 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Tue, 17 Oct 2023 10:26:39 +0900 Subject: [PATCH 10/20] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0=20(#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 댓글 갯수를 totalElements로 수정 * feat: 댓글 입력 후 가장 위로 올라가는 기능 추가 --- .../src/components/Recipe/CommentForm/CommentForm.tsx | 9 +++++++-- .../src/components/Recipe/CommentList/CommentList.tsx | 10 +++++----- frontend/src/mocks/data/comments.json | 1 + frontend/src/pages/RecipeDetailPage.tsx | 11 +++++++---- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx index 33c03d1e2..104657552 100644 --- a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx @@ -1,25 +1,29 @@ import { Button, Spacing, Text, Textarea, useTheme } from '@fun-eat/design-system'; -import type { ChangeEventHandler, FormEventHandler } from 'react'; +import type { ChangeEventHandler, FormEventHandler, RefObject } from 'react'; import { useState } from 'react'; import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; +import { useScroll } from '@/hooks/common'; import { useToastActionContext } from '@/hooks/context'; import { useRecipeCommentMutation } from '@/hooks/queries/recipe'; interface CommentFormProps { recipeId: number; + scrollTargetRef: RefObject; } const MAX_COMMENT_LENGTH = 200; -const CommentForm = ({ recipeId }: CommentFormProps) => { +const CommentForm = ({ recipeId, scrollTargetRef }: CommentFormProps) => { const [commentValue, setCommentValue] = useState(''); const { mutate } = useRecipeCommentMutation(recipeId); const theme = useTheme(); const { toast } = useToastActionContext(); + const { scrollToPosition } = useScroll(); + const handleCommentInput: ChangeEventHandler = (e) => { setCommentValue(e.target.value); }; @@ -32,6 +36,7 @@ const CommentForm = ({ recipeId }: CommentFormProps) => { { onSuccess: () => { setCommentValue(''); + scrollToPosition(scrollTargetRef); toast.success('댓글이 등록되었습니다.'); }, onError: (error) => { diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx index 093d5d93e..0330c7059 100644 --- a/frontend/src/components/Recipe/CommentList/CommentList.tsx +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -16,20 +16,20 @@ const CommentList = ({ recipeId }: CommentListProps) => { const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId)); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); - const comments = data.pages.flatMap((page) => page.comments); + const [{ totalElements, comments }] = data.pages.flatMap((page) => page); return ( -
    + <> - 댓글 ({comments.length}개) + 댓글 ({totalElements}개) - {comments.length === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} + {totalElements === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} {comments.map((comment) => ( ))}
    -
    + ); }; diff --git a/frontend/src/mocks/data/comments.json b/frontend/src/mocks/data/comments.json index acf2f9b08..d2f029c98 100644 --- a/frontend/src/mocks/data/comments.json +++ b/frontend/src/mocks/data/comments.json @@ -1,5 +1,6 @@ { "hasNext": false, + "totalElements": 3, "comments": [ { "author": { diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index ed68acb7b..73f430c51 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,6 +1,6 @@ import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense } from 'react'; +import { Suspense, useRef } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; @@ -13,8 +13,9 @@ import { getFormattedDate } from '@/utils/date'; export const RecipeDetailPage = () => { const { recipeId } = useParams(); - const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); + const scrollTargetRef = useRef(null); + const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); const { reset } = useQueryErrorResetBoundary(); const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; @@ -75,11 +76,13 @@ export const RecipeDetailPage = () => { }> - +
    + +
    - + ); From 0cddbdf92585cdb949e061f9112289ca463c277d Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Tue, 17 Oct 2023 19:10:18 +0900 Subject: [PATCH 11/20] =?UTF-8?q?[BE]=20refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ProductsInCategoryResponse에서 PageDto(페이징에 대한 자세한 정보)를 제거하고 hasNext값만 갖도록 수정 * refactor: ProductRepository의 findAllByCategory와 findAllByCategoryByReviewCountDesc 메소드를 findAllByCategory메소드로 통합 및 반환타입 수정 - ReviewCount 반정규화로 인해 메소드 분리 필요성 없어짐 - ReviewCount를 위한 join 쿼리 제거 - 페이징에 대한 자세한 정보(ex. 전체 페이지 수등) 필요없기 때문에 반환값을 Page에서 Slice로 수정 * refactor: ProductService의 reviewCount로 인한 분기 처리 부분 제거 및 PageDto대신 hasNext값으로 response 만들도록 수정 * test: 상품 목록 조회 인수테스트에서 페이지 검증 대신 다음 페이지 유무를 검증하도록 수정 * test: findAllByCategoryOrderByReviewCountDesc테스트를 findAllByCategory 테스트에 통합 * feat: 상품목록조회 api 수정사항 반영 (기존) sort=price,asc&page=1 (수정) sort=price,asc&id=5 * feat: 정렬 조건별 메소드 생성 * feat: ProductService에서 정렬 조건별 분기처리 * test: 상품목록조회api 변경사항 인수테스트에 반영 * chore: toString 제거 * fix: findProductByReviewCountDesc 메소드 오류 수정 * feat: specification을 이용한 동적 쿼리 적용 * feat: count 쿼리 안나가도록 수정 * refactor: 정렬조건(sortBy, sortOrder)용 dto인 ProductSortCondition 추가 * refactor: ProductSpecification의 메소드명 변경 * refactor: 리뷰 반영 * chore: 충돌 해결 * fix: 테스트 fail 해결 --- .../common/repository/BaseRepository.java | 3 + .../common/repository/BaseRepositoryImpl.java | 11 ++ .../product/application/ProductService.java | 46 +++-- .../com/funeat/product/domain/Product.java | 10 ++ .../product/dto/ProductInCategoryDto.java | 5 + .../product/dto/ProductSortCondition.java | 25 +++ .../dto/ProductsInCategoryResponse.java | 16 +- .../product/exception/ProductErrorCode.java | 1 + .../product/exception/ProductException.java | 6 + .../persistence/ProductRepository.java | 25 +-- .../persistence/ProductSpecification.java | 108 +++++++++++ .../presentation/ProductApiController.java | 7 +- .../presentation/ProductController.java | 4 +- .../product/ProductAcceptanceTest.java | 61 +++---- .../acceptance/product/ProductSteps.java | 4 +- .../com/funeat/fixture/ProductFixture.java | 16 ++ .../persistence/ProductRepositoryTest.java | 168 +----------------- 17 files changed, 263 insertions(+), 253 deletions(-) create mode 100644 backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java create mode 100644 backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepository.java b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java index 9c7197243..448db7766 100644 --- a/backend/src/main/java/com/funeat/common/repository/BaseRepository.java +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java @@ -1,6 +1,7 @@ package com.funeat.common.repository; import java.io.Serializable; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -11,4 +12,6 @@ public interface BaseRepository extends JpaRepository { Page findAllForPagination(final Specification spec, final Pageable pageable, final Long totalElements); + + List findAllWithSpecification(final Specification spec, final int pageSize); } diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java index 773b95269..64cd508f6 100644 --- a/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java @@ -1,12 +1,15 @@ package com.funeat.common.repository; import java.io.Serializable; +import java.util.List; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.support.JpaEntityInformation; import org.springframework.data.jpa.repository.support.SimpleJpaRepository; @@ -37,4 +40,12 @@ public Page findAllForPagination(final Specification spec, final Pageable return new PageImpl<>(query.getResultList(), PageRequest.of(0, pageSize), totalElements); } + + @Override + public List findAllWithSpecification(final Specification spec, final int pageSize) { + final TypedQuery query = getQuery(spec, Sort.unsorted()); + query.setMaxResults(pageSize); + + return query.getResultList(); + } } 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 0301db183..921d07d7a 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -9,6 +9,7 @@ import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductResponse; import com.funeat.product.dto.ProductReviewCountDto; +import com.funeat.product.dto.ProductSortCondition; import com.funeat.product.dto.ProductsInCategoryResponse; import com.funeat.product.dto.RankingProductDto; import com.funeat.product.dto.RankingProductsResponse; @@ -21,6 +22,7 @@ import com.funeat.product.persistence.CategoryRepository; import com.funeat.product.persistence.ProductRecipeRepository; import com.funeat.product.persistence.ProductRepository; +import com.funeat.product.persistence.ProductSpecification; import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.domain.RecipeImage; import com.funeat.recipe.dto.RecipeDto; @@ -32,11 +34,11 @@ import com.funeat.tag.domain.Tag; import java.util.Comparator; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,8 +48,9 @@ public class ProductService { private static final int THREE = 3; private static final int TOP = 0; - public static final String REVIEW_COUNT = "reviewCount"; private static final int RANKING_SIZE = 3; + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; private final CategoryRepository categoryRepository; private final ProductRepository productRepository; @@ -60,7 +63,8 @@ public class ProductService { public ProductService(final CategoryRepository categoryRepository, final ProductRepository productRepository, final ReviewTagRepository reviewTagRepository, final ReviewRepository reviewRepository, final ProductRecipeRepository productRecipeRepository, - final RecipeImageRepository recipeImageRepository, final RecipeRepository recipeRepository) { + final RecipeImageRepository recipeImageRepository, + final RecipeRepository recipeRepository) { this.categoryRepository = categoryRepository; this.productRepository = productRepository; this.reviewTagRepository = reviewTagRepository; @@ -70,25 +74,39 @@ public ProductService(final CategoryRepository categoryRepository, final Product this.recipeRepository = recipeRepository; } - public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId, - final Pageable pageable) { + public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId, final Long lastProductId, + final ProductSortCondition sortCondition) { final Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new CategoryNotFoundException(CATEGORY_NOT_FOUND, categoryId)); + final Product lastProduct = productRepository.findById(lastProductId).orElse(null); - final Page pages = getAllProductsInCategory(pageable, category); + final Specification specification = ProductSpecification.searchBy(category, lastProduct, sortCondition); + final List findResults = productRepository.findAllWithSpecification(specification, DEFAULT_CURSOR_PAGINATION_SIZE); - final PageDto pageDto = PageDto.toDto(pages); - final List productDtos = pages.getContent(); + final List productDtos = getProductInCategoryDtos(findResults); + final boolean hasNext = hasNextPage(findResults); - return ProductsInCategoryResponse.toResponse(pageDto, productDtos); + return ProductsInCategoryResponse.toResponse(hasNext, productDtos); } - private Page getAllProductsInCategory(final Pageable pageable, final Category category) { - if (Objects.nonNull(pageable.getSort().getOrderFor(REVIEW_COUNT))) { - final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); - return productRepository.findAllByCategoryOrderByReviewCountDesc(category, pageRequest); + private List getProductInCategoryDtos(final List findProducts) { + final int resultSize = getResultSize(findProducts); + final List products = findProducts.subList(0, resultSize); + + return products.stream() + .map(ProductInCategoryDto::toDto) + .collect(Collectors.toList()); + } + + private int getResultSize(final List findProducts) { + if (findProducts.size() < DEFAULT_CURSOR_PAGINATION_SIZE) { + return findProducts.size(); } - return productRepository.findAllByCategory(category, pageable); + return DEFAULT_PAGE_SIZE; + } + + private boolean hasNextPage(final List findProducts) { + return findProducts.size() > DEFAULT_PAGE_SIZE; } public ProductResponse findProductDetail(final Long productId) { diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index 512f77f8f..b3dfa60a1 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -62,6 +62,16 @@ public Product(final String name, final Long price, final String image, final St this.category = category; } + public Product(final String name, final Long price, final String image, final String content, + final Category category, final Long reviewCount) { + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.category = category; + this.reviewCount = reviewCount; + } + public static Product create(final String name, final Long price, final String content, final Category category) { return new Product(name, price, null, content, category); } diff --git a/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java index 7ab4bf467..e4c73b606 100644 --- a/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java +++ b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java @@ -21,6 +21,11 @@ public ProductInCategoryDto(final Long id, final String name, final Long price, this.reviewCount = reviewCount; } + public static ProductInCategoryDto toDto(final Product product) { + return new ProductInCategoryDto(product.getId(), product.getName(), product.getPrice(), product.getImage(), + product.getAverageRating(), product.getReviewCount()); + } + public static ProductInCategoryDto toDto(final Product product, final Long reviewCount) { return new ProductInCategoryDto(product.getId(), product.getName(), product.getPrice(), product.getImage(), product.getAverageRating(), reviewCount); diff --git a/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java b/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java new file mode 100644 index 000000000..8a929f99c --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java @@ -0,0 +1,25 @@ +package com.funeat.product.dto; + +public class ProductSortCondition { + + private final String by; + private final String order; + + private ProductSortCondition(final String by, final String order) { + this.by = by; + this.order = order; + } + + public static ProductSortCondition toDto(final String sort) { + final String[] split = sort.split(","); + return new ProductSortCondition(split[0], split[1]); + } + + public String getBy() { + return by; + } + + public String getOrder() { + return order; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java index 4712e90fb..39c685268 100644 --- a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java +++ b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java @@ -1,24 +1,24 @@ package com.funeat.product.dto; -import com.funeat.common.dto.PageDto; import java.util.List; public class ProductsInCategoryResponse { - private final PageDto page; + private final boolean hasNext; private final List products; - public ProductsInCategoryResponse(final PageDto page, final List products) { - this.page = page; + public ProductsInCategoryResponse(final boolean hasNext, final List products) { + this.hasNext = hasNext; this.products = products; } - public static ProductsInCategoryResponse toResponse(final PageDto page, final List products) { - return new ProductsInCategoryResponse(page, products); + public static ProductsInCategoryResponse toResponse(final boolean hasNext, + final List products) { + return new ProductsInCategoryResponse(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/exception/ProductErrorCode.java b/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java index 933f91098..e3b4d3ccc 100644 --- a/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java +++ b/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java @@ -5,6 +5,7 @@ public enum ProductErrorCode { PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다. 상품 id를 확인하세요.", "1001"), + NOT_SUPPORTED_PRODUCT_SORTING_CONDITION(HttpStatus.BAD_REQUEST, "정렬 조건이 올바르지 않습니다. 정렬 조건을 확인하세요", "1002"); ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/product/exception/ProductException.java b/backend/src/main/java/com/funeat/product/exception/ProductException.java index c9b1f1720..bdbb4782b 100644 --- a/backend/src/main/java/com/funeat/product/exception/ProductException.java +++ b/backend/src/main/java/com/funeat/product/exception/ProductException.java @@ -15,4 +15,10 @@ public ProductNotFoundException(final ProductErrorCode errorCode, final Long pro super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), productId)); } } + + public static class NotSupportedProductSortingConditionException extends ProductException { + public NotSupportedProductSortingConditionException(final ProductErrorCode errorCode, final String sortBy) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), sortBy)); + } + } } 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 c3adc5c4b..9b4036361 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -1,36 +1,15 @@ package com.funeat.product.persistence; -import com.funeat.product.domain.Category; +import com.funeat.common.repository.BaseRepository; import com.funeat.product.domain.Product; -import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductReviewCountDto; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { - - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " - + "FROM Product p " - + "LEFT JOIN p.reviews r " - + "WHERE p.category = :category " - + "GROUP BY p ", - countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") - Page findAllByCategory(@Param("category") final Category category, final Pageable pageable); - - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " - + "FROM Product p " - + "LEFT JOIN p.reviews r " - + "WHERE p.category = :category " - + "GROUP BY p " - + "ORDER BY COUNT(r) DESC, p.id DESC ", - countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") - Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, - final Pageable pageable); +public interface ProductRepository extends BaseRepository { @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + "FROM Product p " diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java b/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java new file mode 100644 index 000000000..f2dbd2ff2 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java @@ -0,0 +1,108 @@ +package com.funeat.product.persistence; + +import static com.funeat.product.exception.ProductErrorCode.NOT_SUPPORTED_PRODUCT_SORTING_CONDITION; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.ProductSortCondition; +import com.funeat.product.exception.ProductException.NotSupportedProductSortingConditionException; +import java.util.Objects; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; + +public class ProductSpecification { + + private ProductSpecification() { + } + + private static final String DESC = "desc"; + private static final String CATEGORY = "category"; + private static final String ID = "id"; + private static final String REVIEW_COUNT = "reviewCount"; + private static final String AVERAGE_RATING = "averageRating"; + private static final String PRICE = "price"; + + public static Specification searchBy(final Category category, final Product lastProduct, + final ProductSortCondition sortCondition) { + return (root, query, builder) -> { + setOrderBy(sortCondition, root, query, builder); + + return Specification + .where(sameCategory(category)) + .and(nextCursor(lastProduct, sortCondition)) + .toPredicate(root, query, builder); + }; + } + + private static void setOrderBy(final ProductSortCondition sortCondition, final Root root, + final CriteriaQuery query, final CriteriaBuilder builder) { + final String sortBy = sortCondition.getBy(); + final String sortOrder = sortCondition.getOrder(); + + if (DESC.equals(sortOrder)) { + query.orderBy(builder.desc(root.get(sortBy)), builder.desc(root.get(ID))); + } else { + query.orderBy(builder.asc(root.get(sortBy)), builder.desc(root.get(ID))); + } + } + + private static Specification sameCategory(final Category category) { + return (root, query, builder) -> { + final Path categoryPath = root.get(CATEGORY); + + return builder.equal(categoryPath, category); + }; + } + + private static Specification nextCursor(final Product lastProduct, final ProductSortCondition sortCondition) { + final String sortBy = sortCondition.getBy(); + final String sortOrder = sortCondition.getOrder(); + + return (root, query, builder) -> { + if (Objects.isNull(lastProduct)) { + return null; + } + + final Comparable comparisonValue = (Comparable) getComparisonValue(lastProduct, sortBy); + + return builder.or( + sameValueAndSmallerId(sortBy, lastProduct.getId(), comparisonValue).toPredicate(root, query, builder), + nextValue(sortBy, sortOrder, comparisonValue).toPredicate(root, query, builder) + ); + }; + } + + private static Object getComparisonValue(final Product lastProduct, final String sortBy) { + if (PRICE.equals(sortBy)) { + return lastProduct.getPrice(); + } + if (AVERAGE_RATING.equals(sortBy)) { + return lastProduct.getAverageRating(); + } + if (REVIEW_COUNT.equals(sortBy)) { + return lastProduct.getReviewCount(); + } + throw new NotSupportedProductSortingConditionException(NOT_SUPPORTED_PRODUCT_SORTING_CONDITION, sortBy); + } + + private static Specification sameValueAndSmallerId(final String sortBy, final Long lastProductId, + final Comparable comparisonValue) { + return (root, query, builder) -> builder.and( + builder.equal(root.get(sortBy), comparisonValue), + builder.lessThan(root.get(ID), lastProductId)); + } + + private static Specification nextValue(final String sortBy, final String sortOrder, + final Comparable comparisonValue) { + return (root, query, builder) -> { + if (DESC.equals(sortOrder)) { + return builder.lessThan(root.get(sortBy), comparisonValue); + } else { + return builder.greaterThan(root.get(sortBy), comparisonValue); + } + }; + } +} 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 f71a1a706..46435aee7 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -2,6 +2,7 @@ import com.funeat.product.application.ProductService; import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductSortCondition; import com.funeat.product.dto.ProductsInCategoryResponse; import com.funeat.product.dto.RankingProductsResponse; import com.funeat.product.dto.SearchProductResultsResponse; @@ -29,8 +30,10 @@ public ProductApiController(final ProductService productService) { @GetMapping("/categories/{categoryId}/products") public ResponseEntity getAllProductsInCategory(@PathVariable final Long categoryId, - @PageableDefault final Pageable pageable) { - final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, pageable); + @RequestParam final Long lastProductId, + @RequestParam final String sort) { + final ProductSortCondition sortCondition = ProductSortCondition.toDto(sort); + final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, lastProductId, sortCondition); 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 d7f9e653f..3fa704eb7 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -26,7 +26,9 @@ public interface ProductController { ) @GetMapping ResponseEntity getAllProductsInCategory( - @PathVariable(name = "category_id") final Long categoryId, @PageableDefault final Pageable pageable + @PathVariable final Long categoryId, + @RequestParam final Long lastProductId, + @RequestParam final String sort ); @Operation(summary = "해당 상품 상세 조회", description = "해당 상품 상세정보를 조회한다.") 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 03b2d9032..5a73e6554 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -2,6 +2,7 @@ import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.다음_데이터가_있는지_검증한다; import static com.funeat.acceptance.common.CommonSteps.사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.여러개_사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.정상_처리; @@ -124,14 +125,12 @@ class 가격_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격4000원_평점4점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품1, 상품2)); } @@ -144,14 +143,12 @@ class 가격_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품2, 상품1)); } } @@ -168,14 +165,12 @@ class 가격_기준_오름차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격4000원_평점4점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품1, 상품3, 상품2)); } @@ -188,14 +183,12 @@ class 가격_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격1000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -212,14 +205,12 @@ class 평점_기준_내림차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점5점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(2L, 1L, 3L)); } @@ -232,14 +223,12 @@ class 평점_기준_내림차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -256,14 +245,12 @@ class 평점_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(1L, 3L, 2L)); } @@ -276,14 +263,12 @@ class 평점_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -305,14 +290,12 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품2, 사진_명세_요청(이미지2), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품2, 사진_명세_요청(이미지3), 리뷰추가요청_재구매O_생성(점수_2점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품2, 상품1, 상품3)); } @@ -325,14 +308,12 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격5000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격3000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품2, 상품1)); } } 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 8092bc187..d41bd5be1 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -10,10 +10,10 @@ public class ProductSteps { public static ExtractableResponse 카테고리별_상품_목록_조회_요청(final Long categoryId, final String sort, - final Long page) { + final Long lastProductId) { return given() .queryParam("sort", sort) - .queryParam("page", page) + .queryParam("lastProductId", lastProductId) .when() .get("/api/categories/{category_id}/products", categoryId) .then() diff --git a/backend/src/test/java/com/funeat/fixture/ProductFixture.java b/backend/src/test/java/com/funeat/fixture/ProductFixture.java index 15b2fa27e..6bb7da0d9 100644 --- a/backend/src/test/java/com/funeat/fixture/ProductFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ProductFixture.java @@ -130,6 +130,22 @@ public class ProductFixture { return new Product("애플망고", 3000L, "image.png", "맛있는 애플망고", 5.0, category); } + public static Product 상품_삼각김밥_가격5000원_리뷰0개_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", category, 0L); + } + + public static Product 상품_삼각김밥_가격2000원_리뷰1개_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", category, 1L); + } + + public static Product 상품_삼각김밥_가격1000원_리뷰3개_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", category, 3L); + } + + public static Product 상품_삼각김밥_가격3000원_리뷰5개_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", category, 5L); + } + public static ProductRecipe 레시피_안에_들어가는_상품_생성(final Product product, final Recipe recipe) { return new ProductRecipe(product, recipe); } 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 887958516..e1eff6623 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -6,28 +6,31 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; 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.PageFixture.페이지요청_생성; import static com.funeat.fixture.PageFixture.평균_평점_내림차순; import static com.funeat.fixture.PageFixture.평균_평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_리뷰3개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_리뷰1개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_리뷰5개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_리뷰0개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; import static org.assertj.core.api.Assertions.assertThat; @@ -42,167 +45,6 @@ @SuppressWarnings("NonAsciiCharacters") class ProductRepositoryTest extends RepositoryTest { - @Nested - class findByAllCategory_성공_테스트 { - - @Test - void 카테고리별_상품을_평점이_높은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); - final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); - final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 평균_평점_내림차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_평점이_낮은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); - final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); - final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 평균_평점_오름차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_가격이_높은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 가격_내림차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_가격이_낮은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 가격_오름차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - - @Nested - class findAllByCategoryOrderByReviewCountDesc_성공_테스트 { - - @Test - void 카테고리별_상품을_리뷰수가_많은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4); - - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); - final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); - final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product2, 0L); - final var review2_2 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product2, 0L); - final var review2_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product2, 0L); - final var review3_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product3, 0L); - 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); - - final var page = 페이지요청_기본_생성(0, 3); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product2, 3L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product1, 2L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 1L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategoryOrderByReviewCountDesc(category, page) - .getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findAllByAverageRatingGreaterThan3_성공_테스트 { From 16de45ce64f79df3fa4071da95068b22aee91db7 Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Wed, 18 Oct 2023 10:14:03 +0900 Subject: [PATCH 12/20] =?UTF-8?q?[BE]=20fix:=20ReviewDeleteEventListenerTe?= =?UTF-8?q?st=20=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: image가 있을 경우에만 delete로직 작동하도록 수정 * test: ReviewDeleteEventListenerTest 통과하도록 수정 * test: EventTest에 DataClearExtension 추가 * test: 테스트에 사용되는 fixture 변경 * test: event 초기화 로직 추가 * refactor: events.clear()를 @AfterEach로 추출 * test: @SuppressWarnings("NonAsciiCharacters") 추가 * test: 저장 방식 롤백 * test: 변수명 수정 (member -> author) --- .../ReviewDeleteEventListener.java | 2 +- .../java/com/funeat/common/EventTest.java | 20 +-- .../com/funeat/fixture/ReviewFixture.java | 4 + .../ReviewDeleteEventListenerTest.java | 131 +++++------------- 4 files changed, 50 insertions(+), 107 deletions(-) diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java index 2009e3936..a92c4f943 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java @@ -20,7 +20,7 @@ public ReviewDeleteEventListener(final ImageUploader imageUploader) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void deleteReviewImageInS3(final ReviewDeleteEvent event) { final String image = event.getImage(); - if (StringUtils.isBlank(image)) { + if (!StringUtils.isBlank(image)) { imageUploader.delete(image); } } diff --git a/backend/src/test/java/com/funeat/common/EventTest.java b/backend/src/test/java/com/funeat/common/EventTest.java index dec401bec..aeb72d418 100644 --- a/backend/src/test/java/com/funeat/common/EventTest.java +++ b/backend/src/test/java/com/funeat/common/EventTest.java @@ -8,9 +8,8 @@ import com.funeat.product.persistence.ProductRepository; import com.funeat.review.application.ReviewService; import com.funeat.review.persistence.ReviewRepository; -import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; -import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,13 +21,17 @@ @SpringBootTest @RecordApplicationEvents -@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") @DisplayNameGeneration(ReplaceUnderscores.class) +@ExtendWith({MockitoExtension.class, DataClearExtension.class}) public class EventTest { @Autowired protected ApplicationEvents events; + @Autowired + protected ReviewService reviewService; + @Autowired protected ProductRepository productRepository; @@ -44,8 +47,10 @@ public class EventTest { @Autowired protected ReviewRepository reviewRepository; - @Autowired - protected ReviewService reviewService; + @AfterEach + void tearDown() { + events.clear(); + } protected Long 단일_상품_저장(final Product product) { return productRepository.save(product).getId(); @@ -55,11 +60,6 @@ public class EventTest { return categoryRepository.save(category).getId(); } - protected void 복수_태그_저장(final Tag... tagsToSave) { - final var tags = List.of(tagsToSave); - tagRepository.saveAll(tags); - } - protected Long 단일_멤버_저장(final Member member) { return memberRepository.save(member).getId(); } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index d68a09b6b..2538458ee 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -75,6 +75,10 @@ public class ReviewFixture { return new Review(member, product, "test5", 5L, "test", false, count); } + public static Review 리뷰_이미지없음_평점1점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "", 1L, "test", false, count); + } + public static ReviewCreateRequest 리뷰추가요청_생성(final Long rating, final List tagIds, final String content, final Boolean rebuy) { return new ReviewCreateRequest(rating, tagIds, content, rebuy); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java index 5edf33f36..9f5dfb73b 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -1,13 +1,14 @@ package com.funeat.review.application; import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; -import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; -import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; -import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; -import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매X_생성; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; @@ -18,14 +19,11 @@ import com.funeat.common.ImageUploader; import com.funeat.common.exception.CommonException.S3DeleteFailException; import com.funeat.exception.CommonErrorCode; -import com.funeat.tag.domain.Tag; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; +@SuppressWarnings("NonAsciiCharacters") class ReviewDeleteEventListenerTest extends EventTest { @MockBean @@ -37,30 +35,19 @@ class 리뷰_삭제_이벤트_발행 { @Test void 리뷰_작성자가_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행된다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test1_평점1점_재구매O_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then final var count = events.stream(ReviewDeleteEvent.class).count(); @@ -70,33 +57,23 @@ class 리뷰_삭제_이벤트_발행 { @Test void 리뷰_작성자가_아닌_사람이_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행되지_않는다() { // given - final var author = 멤버_멤버2_생성(); + final var author = 멤버_멤버1_생성(); final var authorId = 단일_멤버_저장(author); - final var member = 멤버_멤버1_생성(); + + final var member = 멤버_멤버2_생성(); final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); + 단일_상품_저장(product); - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); - - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, authorId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test2_평점2점_재구매O_생성(author, product, 0L)); // when try { - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), memberId); } catch (Exception ignored) { } @@ -112,106 +89,68 @@ class 이미지_삭제_로직_작동 { @Test void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test3_평점3점_재구매O_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - verify(uploader, timeout(100).times(1)).delete(any()); + verify(uploader, timeout(1000).times(1)).delete(any()); } @Test void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); + 단일_상품_저장(product); - final var tagIds = 태그_아이디_변환(tag1, tag2); - - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, null, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지없음_평점1점_재구매X_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - verify(uploader, timeout(100).times(0)).delete(any()); + verify(uploader, timeout(1000).times(0)).delete(any()); } @Test void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test4_평점4점_재구매O_생성(author, product, 0L)); doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) .when(uploader) .delete(any()); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - assertThat(reviewRepository.findById(reviewId)).isEmpty(); + assertThat(reviewRepository.findById(review.getId())).isEmpty(); } } - - private List 태그_아이디_변환(final Tag... tags) { - return Stream.of(tags) - .map(Tag::getId) - .collect(Collectors.toList()); - } } From 9467d94f98f710aa5b7ce48ae97fd9bb0f3a85a8 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:58:47 +0900 Subject: [PATCH 13/20] =?UTF-8?q?[BE]=20refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=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(#737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰의 랭킹 점수 계산 로직 추가 * test: 리뷰 랭킹 점수 계산 관련 테스트 추가 * refactor: 리뷰 랭킹 기능 수정 * test: 리뷰 랭킹 서비스 테스트 추가 * style: import 와일드카드 제거 * refactor: 좋아요 1개 이상인 리뷰만 랭킹에 들어갈 수 있도록 수정 * refactor: 사용하지 않는 메서드 및 테스트 삭제 * test: findReviewsByFavoriteCountGreaterThanEqual 테스트 추가 * style: ReviewServiceTest 와일드카드 제거 * style: import 정렬 순서 변경 * fix: 충돌 해결 --- .../review/application/ReviewService.java | 10 +- .../java/com/funeat/review/domain/Review.java | 21 +++ .../review/persistence/ReviewRepository.java | 6 +- .../com/funeat/fixture/ReviewFixture.java | 6 + .../review/application/ReviewServiceTest.java | 156 ++++++++++++++++++ .../com/funeat/review/domain/ReviewTest.java | 40 +++++ .../persistence/ReviewRepositoryTest.java | 96 ++++++----- 7 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 backend/src/test/java/com/funeat/review/domain/ReviewTest.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 320bc13eb..5719e8dd0 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -37,6 +37,7 @@ import com.funeat.review.specification.SortingReviewSpecification; import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -58,6 +59,8 @@ public class ReviewService { private static final int START_INDEX = 0; private static final int ONE = 1; private static final String EMPTY_URL = ""; + private static final int RANKING_SIZE = 3; + private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L; private static final int REVIEW_PAGE_SIZE = 10; private final ReviewRepository reviewRepository; @@ -208,9 +211,10 @@ private Boolean hasNextPage(final List sortingReviews) { } public RankingReviewsResponse getTopReviews() { - final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); - - final List dtos = rankingReviews.stream() + final List reviews = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT); + final List dtos = reviews.stream() + .sorted(Comparator.comparing(Review::calculateRankingScore).reversed()) + .limit(RANKING_SIZE) .map(RankingReviewDto::toDto) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index d990666d3..9b1a458d3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -4,6 +4,7 @@ import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.product.domain.Product; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -20,6 +21,8 @@ @Entity public class Review { + private static final double RANKING_GRAVITY = 0.5; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -80,6 +83,18 @@ public Review(final Member member, final Product findProduct, final String image this.favoriteCount = favoriteCount; } + public Review(final Member member, final Product findProduct, final String image, final Long rating, + final String content, final Boolean reBuy, final Long favoriteCount, final LocalDateTime createdAt) { + this.member = member; + this.product = findProduct; + this.image = image; + this.rating = rating; + this.content = content; + this.reBuy = reBuy; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + } + public void addFavoriteCount() { this.favoriteCount++; } @@ -88,6 +103,12 @@ public void minusFavoriteCount() { this.favoriteCount--; } + 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 boolean checkAuthor(final Member member) { return Objects.equals(this.member, member); } diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java index 1cf889b0b..69d35018d 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -5,8 +5,6 @@ import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; -import com.funeat.review.dto.SortingReviewDto; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -18,8 +16,6 @@ public interface ReviewRepository extends JpaRepository, ReviewCustomRepository { - List findTop3ByOrderByFavoriteCountDescIdDesc(); - Long countByProduct(final Product product); Page findReviewsByMember(final Member findMember, final Pageable pageable); @@ -36,4 +32,6 @@ public interface ReviewRepository extends JpaRepository, ReviewCus List findPopularReviewWithImage(@Param("id") final Long productId, final Pageable pageable); Optional findTopByProductOrderByFavoriteCountDescIdDesc(final Product product); + + List findReviewsByFavoriteCountGreaterThanEqual(final Long favoriteCount); } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index 2538458ee..fee2b0b7b 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -11,6 +11,7 @@ import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewRequest; +import java.time.LocalDateTime; import java.util.List; @SuppressWarnings("NonAsciiCharacters") @@ -75,6 +76,11 @@ public class ReviewFixture { return new Review(member, product, "test5", 5L, "test", false, count); } + public static Review 리뷰_이미지test5_평점5점_재구매X_생성(final Member member, final Product product, final Long count, + final LocalDateTime createdAt) { + return new Review(member, product, "test5", 5L, "test", false, count, createdAt); + } + public static Review 리뷰_이미지없음_평점1점_재구매X_생성(final Member member, final Product product, final Long count) { return new Review(member, product, "", 1L, "test", false, count); } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 2e7c82b43..87a10756c 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -1,5 +1,6 @@ package com.funeat.review.application; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; @@ -8,6 +9,7 @@ import static com.funeat.fixture.PageFixture.페이지요청_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; @@ -18,6 +20,7 @@ import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_좋아요수_내림차순_생성; import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_최신순_생성; @@ -37,11 +40,15 @@ import com.funeat.member.exception.MemberException.MemberNotFoundException; import com.funeat.product.exception.ProductException.ProductNotFoundException; import com.funeat.review.domain.Review; +import com.funeat.review.dto.RankingReviewDto; +import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -1007,6 +1014,155 @@ class getMostFavoriteReview_실패_테스트 { } } + @Nested + class getTopReviews_성공_테스트 { + + @Nested + class 리뷰_개수에_대한_테스트 { + + @Test + void 전체_리뷰가_하나도_없어도_반환값은_있어야한다() { + // given + final var expected = RankingReviewsResponse.toResponse(Collections.emptyList()); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_리뷰가_1개_이상_3개_미만이라도_리뷰가_나와야한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_리뷰_중_랭킹이_높은_상위_3개_리뷰를_구할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(3L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 6L, now.minusDays(2L)); + final var review3 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now); + final var review4 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 5L, now); + 복수_리뷰_저장(review1, review2, review3, review4); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDto3 = RankingReviewDto.toDto(review3); + final var rankingReviewDto4 = RankingReviewDto.toDto(review4); + final var rankingReviewDtos = List.of(rankingReviewDto4, rankingReviewDto3, rankingReviewDto2); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class 리뷰_랭킹_점수에_대한_테스트 { + + @Test + void 리뷰_좋아요_수가_같으면_최근_생성된_리뷰의_랭킹을_더_높게_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(9L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(4L)); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 리뷰_생성_일자가_같으면_좋아요_수가_많은_리뷰의_랭킹을_더_높게_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(1L)); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + } + private List 태그_아이디_변환(final Tag... tags) { return Stream.of(tags) .map(Tag::getId) diff --git a/backend/src/test/java/com/funeat/review/domain/ReviewTest.java b/backend/src/test/java/com/funeat/review/domain/ReviewTest.java new file mode 100644 index 000000000..a9b02876b --- /dev/null +++ b/backend/src/test/java/com/funeat/review/domain/ReviewTest.java @@ -0,0 +1,40 @@ +package com.funeat.review.domain; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; +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 ReviewTest { + + @Nested + class calculateRankingScore_성공_테스트 { + + @Test + void 리뷰_좋아요_수와_리뷰_생성_시간으로_해당_리뷰의_랭킹_점수를_구할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var category = 카테고리_간편식사_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var favoriteCount = 4L; + final var review = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, favoriteCount, LocalDateTime.now().minusDays(1L)); + + final var expected = favoriteCount / Math.pow(2.0, 0.5); + + // when + final var actual = review.calculateRankingScore(); + + // then + assertThat(actual).isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java index ebac6adac..46e4645f4 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -9,15 +9,14 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; 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; @@ -64,42 +63,6 @@ class countByProduct_성공_테스트 { } } - @Nested - class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { - - @Test - void 전체_리뷰_목록에서_가장_좋아요가_높은_상위_3개의_리뷰를_가져온다() { - // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); - 복수_상품_저장(product1, product2); - - final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 5L); - final var review1_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product1, 351L); - final var review1_3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product1, 130L); - final var review2_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 247L); - final var review3_2 = 리뷰_이미지test1_평점1점_재구매X_생성(member3, product2, 83L); - 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_2, review3_2); - - final var expected = List.of(review1_2, review2_2, review1_3); - - // when - final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findPopularReviewWithImage_성공_테스트 { @@ -198,4 +161,61 @@ class findTopByProductOrderByFavoriteCountDescIdDesc_성공_테스트 { assertThat(actual.get()).isEqualTo(review2); } } + + @Nested + class findReviewsByFavoriteCountGreaterThanEqual_성공_테스트 { + + @Test + void 특정_좋아요_수_이상인_모든_리뷰들을_조회한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 1L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 100L); + 복수_리뷰_저장(review1, review2, review3); + + final var expected = List.of(review1, review3); + + // when + final var actual = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 특정_좋아요_수_이상인_리뷰가_없으면_빈_리스트를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var expected = Collections.emptyList(); + + // when + final var actual = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } } 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 14/20] =?UTF-8?q?[BE]=20refactor:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=9E=AD=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=A6=98=20=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 recipes = recipeRepository.findRecipesByOrderByFavoriteCountDesc(PageRequest.of(TOP, THREE)); + final List recipes = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT); final List dtos = recipes.stream() + .sorted(Comparator.comparing(Recipe::calculateRankingScore).reversed()) + .limit(RANKING_SIZE) .map(recipe -> { final List 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 { @Query("SELECT r FROM Recipe r LEFT JOIN ProductRecipe pr ON pr.product = :product WHERE pr.recipe.id = r.id") Page findRecipesByProduct(final Product product, final Pageable pageable); - List findRecipesByOrderByFavoriteCountDesc(final Pageable pageable); - @Lock(PESSIMISTIC_WRITE) @Query("SELECT r FROM Recipe r WHERE r.id=:id") Optional findByIdForUpdate(final Long id); + + List 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() From 111d7eae20dbebc0f4fc0a0c8e4d47753a2ec07b Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:11:13 +0900 Subject: [PATCH 15/20] =?UTF-8?q?[FE]=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: MemberReviewItem과 ReviewRankingItem 분리 * feat: 휴지통 모양 svg icon 추가 * feat: 리뷰 삭제 구현 * feat: 스토리북에 args 추가 * refactor: isMemberPage -> isPreview로 네이밍 수정 --- frontend/.storybook/preview-body.html | 3 + .../src/components/Common/Svg/SvgIcon.tsx | 3 +- .../src/components/Common/Svg/SvgSprite.tsx | 3 + .../MemberRecipeList/MemberRecipeList.tsx | 10 +- .../MemberReviewItem.stories.tsx | 35 +++++ .../MemberReviewItem/MemberReviewItem.tsx | 123 ++++++++++++++++++ .../MemberReviewList/MemberReviewList.tsx | 19 +-- frontend/src/components/Members/index.ts | 1 + .../ReviewRankingItem/ReviewRankingItem.tsx | 16 +-- frontend/src/hooks/queries/members/index.ts | 1 + .../hooks/queries/members/useDeleteReview.ts | 20 +++ frontend/src/mocks/handlers/memberHandlers.ts | 4 + frontend/src/pages/MemberPage.tsx | 4 +- frontend/src/types/review.ts | 10 ++ 14 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx create mode 100644 frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx create mode 100644 frontend/src/hooks/queries/members/useDeleteReview.ts diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 1461bde42..46ce41c44 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -119,6 +119,9 @@ d="M8 7a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3A.5.5 0 0 1 8 7zm0-.75a.749.749 0 1 0 0-1.5.749.749 0 0 0 0 1.498zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8zm6-5a5 5 0 1 0 0 10A5 5 0 0 0 8 3z" /> + + +
    diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index 0705d543a..e31256ae6 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -23,7 +23,8 @@ export const SVG_ICON_VARIANTS = [ 'camera', 'link', 'plane', - 'info' + 'info', + 'trashcan', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index 450c870be..e0fa06dd5 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -86,6 +86,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx index 14558353f..740daddff 100644 --- a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx +++ b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx @@ -10,15 +10,15 @@ import { useInfiniteMemberRecipeQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberRecipeListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { +const MemberRecipeList = ({ isPreview = false }: MemberRecipeListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberRecipeQuery(); const memberRecipes = data?.pages.flatMap((page) => page.recipes); - const recipeToDisplay = useDisplaySlice(isMemberPage, memberRecipes); + const recipeToDisplay = useDisplaySlice(isPreview, memberRecipes); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -40,7 +40,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalRecipeCount}개의 꿀조합을 남겼어요! @@ -50,7 +50,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { {recipeToDisplay?.map((recipe) => (
  • - +
  • ))} diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx new file mode 100644 index 000000000..89827a62c --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import MemberReviewItem from './MemberReviewItem'; + +import ToastProvider from '@/contexts/ToastContext'; + +const meta: Meta = { + title: 'members/MemberReviewItem', + component: MemberReviewItem, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + review: { + reviewId: 1, + productId: 5, + productName: '구운감자슬림명란마요', + content: + '할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다', + rating: 4.0, + favoriteCount: 1256, + categoryType: 'food', + }, + isMemberPage: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx new file mode 100644 index 000000000..1d4503853 --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx @@ -0,0 +1,123 @@ +import { useTheme, Spacing, Text, Button } from '@fun-eat/design-system'; +import type { MouseEventHandler } from 'react'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useToastActionContext } from '@/hooks/context'; +import { useDeleteReview } from '@/hooks/queries/members'; +import type { MemberReview } from '@/types/review'; + +interface MemberReviewItemProps { + review: MemberReview; + isPreview: boolean; +} + +const MemberReviewItem = ({ review, isPreview }: MemberReviewItemProps) => { + const theme = useTheme(); + + const { mutate } = useDeleteReview(); + + const { toast } = useToastActionContext(); + + const { reviewId, productName, content, rating, favoriteCount } = review; + + const handleReviewDelete: MouseEventHandler = (e) => { + e.preventDefault(); + + const result = window.confirm('리뷰를 삭제하시겠습니까?'); + if (!result) { + return; + } + + mutate(reviewId, { + onSuccess: () => { + toast.success('리뷰를 삭제했습니다.'); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('리뷰 좋아요를 다시 시도해주세요.'); + }, + }); + }; + + return ( + + + + {productName} + + {!isPreview && ( + + )} + + + {content} + + + + + + + {favoriteCount} + + + + + + {rating.toFixed(1)} + + + + + ); +}; + +export default MemberReviewItem; + +const ReviewRankingItemContainer = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; +`; + +const ProductNameIconWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const ReviewText = styled(Text)` + display: -webkit-inline-box; + text-overflow: ellipsis; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const FavoriteStarWrapper = styled.div` + display: flex; + gap: 4px; +`; + +const FavoriteIconWrapper = styled.div` + display: flex; + gap: 4px; + align-items: center; +`; + +const RatingIconWrapper = styled.div` + display: flex; + gap: 2px; + align-items: center; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index fde211413..b622d9f65 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -3,21 +3,22 @@ import { useRef } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; -import { ReviewRankingItem } from '@/components/Rank'; +import MemberReviewItem from '../MemberReviewItem/MemberReviewItem'; + import { PATH } from '@/constants/path'; import { useIntersectionObserver } from '@/hooks/common'; import { useInfiniteMemberReviewQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberReviewListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { +const MemberReviewList = ({ isPreview = false }: MemberReviewListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberReviewQuery(); const memberReviews = data.pages.flatMap((page) => page.reviews); - const reviewsToDisplay = useDisplaySlice(isMemberPage, memberReviews); + const reviewsToDisplay = useDisplaySlice(isPreview, memberReviews); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -39,17 +40,17 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalReviewCount}개의 리뷰를 남겼어요! )} - {reviewsToDisplay.map((reviewRanking) => ( -
  • - - + {reviewsToDisplay.map((review) => ( +
  • + +
  • ))} diff --git a/frontend/src/components/Members/index.ts b/frontend/src/components/Members/index.ts index a295e2728..4e31460ee 100644 --- a/frontend/src/components/Members/index.ts +++ b/frontend/src/components/Members/index.ts @@ -2,3 +2,4 @@ export { default as MembersInfo } from './MembersInfo/MembersInfo'; export { default as MemberReviewList } from './MemberReviewList/MemberReviewList'; export { default as MemberRecipeList } from './MemberRecipeList/MemberRecipeList'; export { default as MemberModifyInput } from './MemberModifyInput/MemberModifyInput'; +export { default as MemberReviewItem } from './MemberReviewItem/MemberReviewItem'; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index c2004c20e..ca2aed3dc 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -1,4 +1,4 @@ -import { Spacing, Text, theme } from '@fun-eat/design-system'; +import { Spacing, Text, useTheme } from '@fun-eat/design-system'; import { memo } from 'react'; import styled from 'styled-components'; @@ -7,14 +7,15 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; - isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { + const theme = useTheme(); + const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -42,14 +43,13 @@ const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankin export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` +const ReviewRankingItemContainer = styled.div` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; - border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; - border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; + border: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/hooks/queries/members/index.ts b/frontend/src/hooks/queries/members/index.ts index 9e45e5239..cbd6e7468 100644 --- a/frontend/src/hooks/queries/members/index.ts +++ b/frontend/src/hooks/queries/members/index.ts @@ -3,3 +3,4 @@ export { default as useMemberQuery } from './useMemberQuery'; export { default as useInfiniteMemberRecipeQuery } from './useInfiniteMemberRecipeQuery'; export { default as useMemberModifyMutation } from './useMemberModifyMutation'; export { default as useLogoutMutation } from './useLogoutMutation'; +export { default as useDeleteReview } from './useDeleteReview'; diff --git a/frontend/src/hooks/queries/members/useDeleteReview.ts b/frontend/src/hooks/queries/members/useDeleteReview.ts new file mode 100644 index 000000000..a88169bce --- /dev/null +++ b/frontend/src/hooks/queries/members/useDeleteReview.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { memberApi } from '@/apis'; + +const headers = { 'Content-Type': 'application/json' }; + +const deleteReview = async (reviewId: number) => { + return memberApi.delete({ params: `/reviews/${reviewId}`, credentials: true }, headers); +}; + +const useDeleteReview = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (reviewId: number) => deleteReview(reviewId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['member', 'review'] }), + }); +}; + +export default useDeleteReview; diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index 2abb3d7fb..31ee93536 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -55,4 +55,8 @@ export const memberHandlers = [ return res(ctx.status(200), ctx.json(mockMemberRecipes)); }), + + rest.delete('/api/members/reviews/:reviewId', (req, res, ctx) => { + return res(ctx.status(204)); + }), ]; diff --git a/frontend/src/pages/MemberPage.tsx b/frontend/src/pages/MemberPage.tsx index 7026e4768..58aa6d8e4 100644 --- a/frontend/src/pages/MemberPage.tsx +++ b/frontend/src/pages/MemberPage.tsx @@ -20,14 +20,14 @@ export const MemberPage = () => { }> - + }> - + diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index ced1d2f58..dd0273ddf 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -20,6 +20,16 @@ export interface ReviewDetail extends Review { productName: string; } +export interface MemberReview { + reviewId: number; + productId: number; + productName: string; + content: string; + rating: number; + favoriteCount: number; + categoryType: CategoryVariant; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[]; From e94c576344a2c710e404451d4ce25a1b1f30193b Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:19:27 +0900 Subject: [PATCH 16/20] =?UTF-8?q?[FE]=20fix:=20storybook=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B0=94=EB=80=90=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Members/MemberReviewItem/MemberReviewItem.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx index 89827a62c..a631341d4 100644 --- a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx @@ -25,7 +25,7 @@ const meta: Meta = { favoriteCount: 1256, categoryType: 'food', }, - isMemberPage: true, + isPreview: true, }, }; From 9342c2243887ad53de754fd171c82ef64439d5ba Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:25:15 +0900 Subject: [PATCH 17/20] =?UTF-8?q?[FE]=20refactor:=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20api=20=EB=B3=80=EA=B2=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 목록 페이징 api 변경 * refactor: 리뷰, 상품 페이징 변경 적용 --- .../queries/product/useInfiniteProductReviewsQuery.ts | 7 +++---- .../hooks/queries/product/useInfiniteProductsQuery.ts | 9 +++++---- frontend/src/mocks/data/pbProducts.json | 9 +-------- frontend/src/mocks/data/products.json | 9 +-------- frontend/src/mocks/data/reviews.json | 9 +-------- frontend/src/mocks/handlers/productHandlers.ts | 9 ++------- frontend/src/mocks/handlers/reviewHandlers.ts | 2 +- frontend/src/types/response.ts | 4 ++-- 8 files changed, 16 insertions(+), 42 deletions(-) diff --git a/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts b/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts index 0fb4d604b..8142c77e0 100644 --- a/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts +++ b/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts @@ -6,7 +6,7 @@ import type { ProductReviewResponse } from '@/types/response'; const fetchProductReviews = async (pageParam: number, productId: number, sort: string) => { const res = await productApi.get({ params: `/${productId}/reviews`, - queries: `?sort=${sort}&page=${pageParam}`, + queries: `?sort=${sort}&lastReviewId=${pageParam}`, credentials: true, }); @@ -20,9 +20,8 @@ const useInfiniteProductReviewsQuery = (productId: number, sort: string) => { ({ pageParam = 0 }) => fetchProductReviews(pageParam, productId, sort), { getNextPageParam: (prevResponse: ProductReviewResponse) => { - const isLast = prevResponse.page.lastPage; - const nextPage = prevResponse.page.requestPage + 1; - return isLast ? undefined : nextPage; + const lastCursor = prevResponse.reviews.length ? prevResponse.reviews[prevResponse.reviews.length - 1].id : 0; + return prevResponse.hasNext ? lastCursor : undefined; }, } ); diff --git a/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts b/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts index d8b008126..1b9fdd57f 100644 --- a/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts +++ b/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts @@ -6,7 +6,7 @@ import type { CategoryProductResponse } from '@/types/response'; const fetchProducts = async (pageParam: number, categoryId: number, sort = 'reviewCount,desc') => { const res = await categoryApi.get({ params: `/${categoryId}/products`, - queries: `?page=${pageParam}&sort=${sort}`, + queries: `?lastProductId=${pageParam}&sort=${sort}`, }); const data: CategoryProductResponse = await res.json(); @@ -19,9 +19,10 @@ const useInfiniteProductsQuery = (categoryId: number, sort = 'reviewCount,desc') ({ pageParam = 0 }) => fetchProducts(pageParam, categoryId, sort), { getNextPageParam: (prevResponse: CategoryProductResponse) => { - const isLast = prevResponse.page.lastPage; - const nextPage = prevResponse.page.requestPage + 1; - return isLast ? undefined : nextPage; + const lastCursor = prevResponse.products.length + ? prevResponse.products[prevResponse.products.length - 1].id + : 0; + return prevResponse.hasNext ? lastCursor : undefined; }, } ); diff --git a/frontend/src/mocks/data/pbProducts.json b/frontend/src/mocks/data/pbProducts.json index 12bad382c..e25f1dc48 100644 --- a/frontend/src/mocks/data/pbProducts.json +++ b/frontend/src/mocks/data/pbProducts.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "products": [ { "id": 11, diff --git a/frontend/src/mocks/data/products.json b/frontend/src/mocks/data/products.json index 8d78d4a97..e68a13fbf 100644 --- a/frontend/src/mocks/data/products.json +++ b/frontend/src/mocks/data/products.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "products": [ { "id": 1, diff --git a/frontend/src/mocks/data/reviews.json b/frontend/src/mocks/data/reviews.json index 54b9719ce..5f9d129ee 100644 --- a/frontend/src/mocks/data/reviews.json +++ b/frontend/src/mocks/data/reviews.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "reviews": [ { "id": 1, diff --git a/frontend/src/mocks/handlers/productHandlers.ts b/frontend/src/mocks/handlers/productHandlers.ts index bc230c1fa..32f3feadc 100644 --- a/frontend/src/mocks/handlers/productHandlers.ts +++ b/frontend/src/mocks/handlers/productHandlers.ts @@ -25,7 +25,6 @@ export const productHandlers = [ rest.get('/api/categories/:categoryId/products', (req, res, ctx) => { const sortOptions = req.url.searchParams.get('sort'); const categoryId = req.params.categoryId; - const page = Number(req.url.searchParams.get('page')); if (sortOptions === null) { return res(ctx.status(400)); @@ -37,7 +36,7 @@ export const productHandlers = [ let products = commonProducts; - if (Number(categoryId) >= 7 && Number(categoryId) <= 9) { + if (Number(categoryId) >= 6 && Number(categoryId) <= 9) { products = pbProducts; } @@ -53,11 +52,7 @@ export const productHandlers = [ sortOrder === 'asc' ? cur[key] - next[key] : next[key] - cur[key] ), }; - return res( - ctx.status(200), - ctx.json({ page: sortedProducts.page, products: products.products.slice(page * 10, (page + 1) * 10) }), - ctx.delay(500) - ); + return res(ctx.status(200), ctx.json(sortedProducts), ctx.delay(500)); }), rest.get('/api/products/:productId', (req, res, ctx) => { diff --git a/frontend/src/mocks/handlers/reviewHandlers.ts b/frontend/src/mocks/handlers/reviewHandlers.ts index 5c007c605..656891e55 100644 --- a/frontend/src/mocks/handlers/reviewHandlers.ts +++ b/frontend/src/mocks/handlers/reviewHandlers.ts @@ -42,7 +42,7 @@ export const reviewHandlers = [ return res( ctx.status(200), - ctx.json({ page: sortedReviews.page, reviews: sortedReviews.reviews }), + ctx.json({ hasNext: sortedReviews.hasNext, reviews: sortedReviews.reviews }), ctx.delay(1000) ); }), diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 42c21a24d..bac466ebf 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -14,11 +14,11 @@ export interface Page { } export interface CategoryProductResponse { - page: Page; + hasNext: boolean; products: Product[]; } export interface ProductReviewResponse { - page: Page; + hasNext: boolean; reviews: Review[]; } From 87c5ac8057f8391954d4bf1fe173d04d668c5915 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:30:42 +0900 Subject: [PATCH 18/20] =?UTF-8?q?[BE]=20refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=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(#756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 상품 랭킹 조회 시 기간 설정 * test: 상품 랭킹 관련 Repository 테스트 추가 및 수정 * test: 상품 랭킹 관련 서비스 테스트 수정 * style: import 정렬 순서 변경 * fix: 충돌 해결 --- .../product/application/ProductService.java | 5 +- .../persistence/ProductRepository.java | 5 +- .../application/ProductServiceTest.java | 14 ++-- .../persistence/ProductRepositoryTest.java | 69 +++++++++++-------- 4 files changed, 57 insertions(+), 36 deletions(-) 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 921d07d7a..b094d49e8 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -32,6 +32,7 @@ import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; import com.funeat.tag.domain.Tag; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -119,7 +120,9 @@ public ProductResponse findProductDetail(final Long productId) { } public RankingProductsResponse getTop3Products() { - final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(); + final LocalDateTime endDateTime = LocalDateTime.now(); + final LocalDateTime startDateTime = endDateTime.minusWeeks(2L); + final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); final Comparator rankingScoreComparator = Comparator.comparing( (ProductReviewCountDto it) -> it.getProduct().calculateRankingScore(it.getReviewCount()) ).reversed(); 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 9b4036361..27208f254 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -3,6 +3,7 @@ import com.funeat.common.repository.BaseRepository; import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductReviewCountDto; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,8 +16,10 @@ public interface ProductRepository extends BaseRepository { + "FROM Product p " + "LEFT JOIN Review r ON r.product.id = p.id " + "WHERE p.averageRating > 3.0 " + + "AND r.createdAt BETWEEN :startDateTime AND :endDateTime " + "GROUP BY p.id") - List findAllByAverageRatingGreaterThan3(); + List findAllByAverageRatingGreaterThan3(final LocalDateTime startDateTime, + final LocalDateTime endDateTime); @Query("SELECT p FROM Product p " + "WHERE p.name LIKE CONCAT('%', :name, '%') " diff --git a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java index 5aa812dc6..9f5b88c5b 100644 --- a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java +++ b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java @@ -109,16 +109,16 @@ class 상품_개수에_대한_테스트 { final var review1_4 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); final var review2_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); - final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); - final var review4_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); - final var review4_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member1, product2, 0L); - final var review4_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product3, 0L); + final var review4_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product4, 0L); + final var review4_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member1, product4, 0L); + final var review4_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product4, 0L); 복수_리뷰_저장(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, review3_1, review4_1, review4_2, review4_3); - final var rankingProductDto1 = RankingProductDto.toDto(product2); - final var rankingProductDto2 = RankingProductDto.toDto(product3); - final var rankingProductDto3 = RankingProductDto.toDto(product4); + final var rankingProductDto1 = RankingProductDto.toDto(product3); + final var rankingProductDto2 = RankingProductDto.toDto(product4); + final var rankingProductDto3 = RankingProductDto.toDto(product2); final var rankingProductDtos = List.of(rankingProductDto1, rankingProductDto2, rankingProductDto3); final var expected = RankingProductsResponse.toResponse(rankingProductDtos); 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 e1eff6623..107585475 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -4,40 +4,23 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -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.PageFixture.페이지요청_생성; -import static com.funeat.fixture.PageFixture.평균_평점_내림차순; -import static com.funeat.fixture.PageFixture.평균_평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_리뷰3개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_리뷰1개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_리뷰5개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_리뷰0개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; -import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductReviewCountDto; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -65,12 +48,12 @@ class findAllByAverageRatingGreaterThan3_성공_테스트 { final var member3 = 멤버_멤버3_생성(); 복수_멤버_저장(member1, member2, member3); - final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); - final var review1_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product1, 0L); - final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product2, 0L); - final var review2_2 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); - final var review2_3 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 0L); - final var review3_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product3, 0L); + final var review1_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product1, 0L, LocalDateTime.now().minusDays(2L)); + final var review1_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product1, 0L, LocalDateTime.now().minusDays(3L)); + final var review2_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member3, product2, 0L, LocalDateTime.now().minusDays(10L)); + final var review2_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L, LocalDateTime.now().minusDays(1L)); + final var review2_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product2, 0L, LocalDateTime.now().minusDays(9L)); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product3, 0L, LocalDateTime.now().minusDays(8L)); 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); final var productReviewCountDto1 = new ProductReviewCountDto(product2, 3L); @@ -78,7 +61,39 @@ class findAllByAverageRatingGreaterThan3_성공_테스트 { final var expected = List.of(productReviewCountDto1, productReviewCountDto2); // when - final var actual = productRepository.findAllByAverageRatingGreaterThan3(); + final var startDateTime = LocalDateTime.now().minusWeeks(2L); + final var endDateTime = LocalDateTime.now(); + final var actual = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 기간_안에_리뷰가_존재하는_상품이_없으면_빈_리스트를_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + 복수_상품_저장(product1, product2); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + 복수_멤버_저장(member1, member2); + + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product1, 0L, LocalDateTime.now().minusDays(15L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product2, 0L, LocalDateTime.now().minusWeeks(3L)); + 복수_리뷰_저장(review1, review2); + + final var expected = Collections.emptyList(); + + // when + final var startDateTime = LocalDateTime.now().minusWeeks(2L); + final var endDateTime = LocalDateTime.now(); + final var actual = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); // then assertThat(actual).usingRecursiveComparison() From 6fc930b12a34814bab1f56ea4b4b39d6e8235c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Wed, 18 Oct 2023 17:10:17 +0900 Subject: [PATCH 19/20] =?UTF-8?q?[FE]=20refactor:=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=95=95=EC=B6=95=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EB=8F=84=EC=9E=85=20(#786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: alert를 toast로 수정 * refactor: dialog 컨테이너 div 추가 * refactor: dialog 컨테이너 div 추가 * chore: 디자인 시스템 버전 업 * refactor: dialog 업데이트 * refactor: 이미지 압축 구현 * refactor: 이미지 압축 구현 --- frontend/.storybook/preview-body.html | 2 + frontend/package.json | 4 +- frontend/public/index.html | 1 + .../Common/ImageUploader/ImageUploader.tsx | 4 +- .../SortOptionList/SortOptionList.stories.tsx | 4 +- .../ReviewRegisterForm/ReviewRegisterForm.tsx | 13 ++++--- frontend/src/hooks/common/useImageUploader.ts | 38 +++++++++++++++++-- frontend/src/pages/MemberModifyPage.tsx | 4 +- frontend/src/pages/ProductDetailPage.tsx | 4 +- frontend/src/pages/ProductListPage.tsx | 4 +- frontend/src/pages/RecipePage.tsx | 4 +- frontend/yarn.lock | 8 ++-- 12 files changed, 66 insertions(+), 24 deletions(-) diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 46ce41c44..a37b26cbd 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -124,4 +124,6 @@ +
    + diff --git a/frontend/package.json b/frontend/package.json index ef994ca5e..358f1fe4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,9 +13,10 @@ "test:coverage": "jest --watchAll --coverage" }, "dependencies": { - "@fun-eat/design-system": "^0.3.15", + "@fun-eat/design-system": "^0.3.18", "@tanstack/react-query": "^4.32.6", "@tanstack/react-query-devtools": "^4.32.6", + "browser-image-compression": "^2.0.2", "dayjs": "^1.11.9", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -46,7 +47,6 @@ "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", "babel-plugin-styled-components": "^2.1.4", - "browser-image-compression": "^2.0.2", "copy-webpack-plugin": "^11.0.0", "dotenv-webpack": "^8.0.1", "eslint": "^8.44.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index 0359ca16d..36782ea87 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -27,6 +27,7 @@
    +
    diff --git a/frontend/src/components/Common/ImageUploader/ImageUploader.tsx b/frontend/src/components/Common/ImageUploader/ImageUploader.tsx index bd85c20e3..9c915081e 100644 --- a/frontend/src/components/Common/ImageUploader/ImageUploader.tsx +++ b/frontend/src/components/Common/ImageUploader/ImageUploader.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useEnterKeyDown } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; interface ReviewImageUploaderProps { previewImage: string; @@ -13,6 +14,7 @@ interface ReviewImageUploaderProps { const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUploaderProps) => { const { inputRef, handleKeydown } = useEnterKeyDown(); + const { toast } = useToastActionContext(); const handleImageUpload: ChangeEventHandler = (event) => { if (!event.target.files) { @@ -22,7 +24,7 @@ const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUp const imageFile = event.target.files[0]; if (imageFile.size > IMAGE_MAX_SIZE) { - alert('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); + toast.error('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); event.target.value = ''; return; } diff --git a/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx index 779e68942..5e7c2f935 100644 --- a/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx +++ b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx @@ -17,7 +17,7 @@ type Story = StoryObj; export const Default: Story = { render: () => { - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); useEffect(() => { @@ -25,7 +25,7 @@ export const Default: Story = { }, []); return ( - + { const { scrollToPosition } = useScroll(); - const { previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); + const { isImageUploading, previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); const reviewFormValue = useReviewFormValueContext(); const { resetReviewFormValue } = useReviewFormActionContext(); + const { toast } = useToastActionContext(); const { data: productDetail } = useProductDetailQuery(productId); const { mutate, isLoading } = useReviewRegisterFormMutation(productId); @@ -41,7 +42,8 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe reviewFormValue.rating > MIN_RATING_SCORE && reviewFormValue.tagIds.length >= MIN_SELECTED_TAGS_COUNT && reviewFormValue.tagIds.length <= MIN_DISPLAYED_TAGS_LENGTH && - reviewFormValue.content.length > MIN_CONTENT_LENGTH; + reviewFormValue.content.length > MIN_CONTENT_LENGTH && + !isImageUploading; const formData = useFormData({ imageKey: 'image', @@ -64,15 +66,16 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe resetAndCloseForm(); initTabMenu(); scrollToPosition(targetRef); + toast.success('📝 리뷰가 등록 됐어요'); }, onError: (error) => { resetAndCloseForm(); if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('리뷰 등록을 다시 시도해주세요'); + toast.error('리뷰 등록을 다시 시도해주세요'); }, }); }; diff --git a/frontend/src/hooks/common/useImageUploader.ts b/frontend/src/hooks/common/useImageUploader.ts index c5783ad59..cc093517d 100644 --- a/frontend/src/hooks/common/useImageUploader.ts +++ b/frontend/src/hooks/common/useImageUploader.ts @@ -1,19 +1,50 @@ +import imageCompression from 'browser-image-compression'; import { useState } from 'react'; +import { useToastActionContext } from '../context'; + const isImageFile = (file: File) => file.type !== 'image/png' && file.type !== 'image/jpeg'; +const options = { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + useWebWorker: true, +}; + const useImageUploader = () => { + const { toast } = useToastActionContext(); + const [imageFile, setImageFile] = useState(null); + const [isImageUploading, setIsImageUploading] = useState(false); const [previewImage, setPreviewImage] = useState(''); - const uploadImage = (imageFile: File) => { + const uploadImage = async (imageFile: File) => { if (isImageFile(imageFile)) { - alert('이미지 파일만 업로드 가능합니다.'); + toast.error('이미지 파일만 업로드 가능합니다.'); return; } setPreviewImage(URL.createObjectURL(imageFile)); - setImageFile(imageFile); + + try { + setIsImageUploading(true); + + const compressedFile = await imageCompression(imageFile, options); + const compressedImageFilePromise = imageCompression.getFilefromDataUrl( + await imageCompression.getDataUrlFromFile(compressedFile), + compressedFile.name + ); + compressedImageFilePromise + .then((result) => { + setImageFile(result); + }) + .then(() => { + setIsImageUploading(false); + toast.success('이미지가 성공적으로 등록 됐습니다'); + }); + } catch (error) { + console.log(error); + } }; const deleteImage = () => { @@ -23,6 +54,7 @@ const useImageUploader = () => { }; return { + isImageUploading, previewImage, imageFile, uploadImage, diff --git a/frontend/src/pages/MemberModifyPage.tsx b/frontend/src/pages/MemberModifyPage.tsx index c6c43a8c3..a5c21490b 100644 --- a/frontend/src/pages/MemberModifyPage.tsx +++ b/frontend/src/pages/MemberModifyPage.tsx @@ -8,6 +8,7 @@ import { SectionTitle, SvgIcon } from '@/components/Common'; import { MemberModifyInput } from '@/components/Members'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useFormData, useImageUploader } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useMemberModifyMutation, useMemberQuery } from '@/hooks/queries/members'; import type { MemberRequest } from '@/types/member'; @@ -16,6 +17,7 @@ export const MemberModifyPage = () => { const { mutate } = useMemberModifyMutation(); const { previewImage, imageFile, uploadImage } = useImageUploader(); + const { toast } = useToastActionContext(); const [nickname, setNickname] = useState(member?.nickname ?? ''); const navigate = useNavigate(); @@ -43,7 +45,7 @@ export const MemberModifyPage = () => { const imageFile = event.target.files[0]; if (imageFile.size > IMAGE_MAX_SIZE) { - alert('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); + toast.error('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); event.target.value = ''; return; } diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index f53b61282..6471af213 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -44,7 +44,7 @@ export const ProductDetailPage = () => { const tabRef = useRef(null); const { selectedOption, selectSortOption } = useSortOption(REVIEW_SORT_OPTIONS[0]); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const [activeSheet, setActiveSheet] = useState<'registerReview' | 'sortOption'>('sortOption'); const { gaEvent } = useGA(); @@ -136,7 +136,7 @@ export const ProductDetailPage = () => { /> - + {activeSheet === 'registerReview' ? ( { const { category } = useParams(); const productListRef = useRef(null); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); const { reset } = useQueryErrorResetBoundary(); const { gaEvent } = useGA(); @@ -68,7 +68,7 @@ export const ProductListPage = () => { - + ('sortOption'); const { selectedOption, selectSortOption } = useSortOption(RECIPE_SORT_OPTIONS[0]); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { reset } = useQueryErrorResetBoundary(); const { gaEvent } = useGA(); @@ -72,7 +72,7 @@ export const RecipePage = () => { /> - + {activeSheet === 'sortOption' ? ( Date: Wed, 18 Oct 2023 20:17:17 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor:=20alert=EB=A5=BC=20toast?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Recipe/RecipeFavorite/RecipeFavorite.tsx | 6 +++++- .../Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx | 7 ++++--- frontend/src/components/Review/ReviewItem/ReviewItem.tsx | 7 +++++-- frontend/src/hooks/queries/members/useLogoutMutation.ts | 7 +++++-- frontend/src/hooks/search/useSearch.ts | 5 ++++- frontend/src/pages/MemberModifyPage.tsx | 4 ++-- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx index 882ca0eed..8df4e9e13 100644 --- a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx +++ b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; import { useTimeout } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useRecipeFavoriteMutation } from '@/hooks/queries/recipe'; interface RecipeFavoriteProps { @@ -15,6 +16,8 @@ interface RecipeFavoriteProps { const RecipeFavorite = ({ recipeId, favorite, favoriteCount }: RecipeFavoriteProps) => { const [isFavorite, setIsFavorite] = useState(favorite); const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount); + const { toast } = useToastActionContext(); + const { mutate } = useRecipeFavoriteMutation(Number(recipeId)); const handleToggleFavorite = async () => { @@ -24,9 +27,10 @@ const RecipeFavorite = ({ recipeId, favorite, favoriteCount }: RecipeFavoritePro onSuccess: () => { setIsFavorite((prev) => !prev); setCurrentFavoriteCount((prev) => (isFavorite ? prev - 1 : prev + 1)); + toast.success('🍯 꿀조합이 등록 됐어요'); }, onError: () => { - alert('꿀조합 좋아요를 다시 시도해주세요.'); + toast.error('꿀조합 좋아요를 다시 시도해주세요.'); }, } ); diff --git a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 238de3574..074d8f4f6 100644 --- a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -8,7 +8,7 @@ import RecipeUsedProducts from '../RecipeUsedProducts/RecipeUsedProducts'; import { ImageUploader, SvgIcon } from '@/components/Common'; import { useImageUploader, useFormData } from '@/hooks/common'; -import { useRecipeFormValueContext, useRecipeFormActionContext } from '@/hooks/context'; +import { useRecipeFormValueContext, useRecipeFormActionContext, useToastActionContext } from '@/hooks/context'; import { useRecipeRegisterFormMutation } from '@/hooks/queries/recipe'; import type { RecipeRequest } from '@/types/recipe'; @@ -23,6 +23,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { const recipeFormValue = useRecipeFormValueContext(); const { resetRecipeFormValue } = useRecipeFormActionContext(); + const { toast } = useToastActionContext(); const formData = useFormData({ imageKey: 'images', @@ -52,11 +53,11 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { onError: (error) => { resetAndCloseForm(); if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('꿀조합 등록을 다시 시도해주세요'); + toast.error('꿀조합 등록을 다시 시도해주세요'); }, }); }; diff --git a/frontend/src/components/Review/ReviewItem/ReviewItem.tsx b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx index 112824ada..14cd83d18 100644 --- a/frontend/src/components/Review/ReviewItem/ReviewItem.tsx +++ b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { SvgIcon, TagList } from '@/components/Common'; import { useTimeout } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useReviewFavoriteMutation } from '@/hooks/queries/review'; import type { Review } from '@/types/review'; import { getRelativeDate } from '@/utils/date'; @@ -18,6 +19,8 @@ const ReviewItem = ({ productId, review }: ReviewItemProps) => { review; const [isFavorite, setIsFavorite] = useState(favorite); const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount); + + const { toast } = useToastActionContext(); const { mutate } = useReviewFavoriteMutation(productId, id); const theme = useTheme(); @@ -32,11 +35,11 @@ const ReviewItem = ({ productId, review }: ReviewItemProps) => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('리뷰 좋아요를 다시 시도해주세요.'); + toast.error('리뷰 좋아요를 다시 시도해주세요.'); }, } ); diff --git a/frontend/src/hooks/queries/members/useLogoutMutation.ts b/frontend/src/hooks/queries/members/useLogoutMutation.ts index 3e586adc7..afc27e2bc 100644 --- a/frontend/src/hooks/queries/members/useLogoutMutation.ts +++ b/frontend/src/hooks/queries/members/useLogoutMutation.ts @@ -3,11 +3,14 @@ import { useNavigate } from 'react-router-dom'; import { logoutApi } from '@/apis'; import { PATH } from '@/constants/path'; +import { useToastActionContext } from '@/hooks/context'; const useLogoutMutation = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { toast } = useToastActionContext(); + return useMutation({ mutationFn: () => logoutApi.post({ credentials: true }), onSuccess: () => { @@ -16,10 +19,10 @@ const useLogoutMutation = () => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('로그아웃을 다시 시도해주세요.'); + toast.error('로그아웃을 다시 시도해주세요.'); }, }); }; diff --git a/frontend/src/hooks/search/useSearch.ts b/frontend/src/hooks/search/useSearch.ts index 4c438d4ec..ba9853940 100644 --- a/frontend/src/hooks/search/useSearch.ts +++ b/frontend/src/hooks/search/useSearch.ts @@ -3,6 +3,7 @@ import { useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useGA } from '../common'; +import { useToastActionContext } from '../context'; const useSearch = () => { const inputRef = useRef(null); @@ -14,6 +15,8 @@ const useSearch = () => { const [isSubmitted, setIsSubmitted] = useState(!!currentSearchQuery); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(searchQuery.length > 0); + const { toast } = useToastActionContext(); + const { gaEvent } = useGA(); const focusInput = () => { @@ -35,7 +38,7 @@ const useSearch = () => { const trimmedSearchQuery = searchQuery.trim(); if (!trimmedSearchQuery) { - alert('검색어를 입력해주세요'); + toast.error('검색어를 입력해주세요'); focusInput(); resetSearchQuery(); return; diff --git a/frontend/src/pages/MemberModifyPage.tsx b/frontend/src/pages/MemberModifyPage.tsx index a5c21490b..6b3031a9e 100644 --- a/frontend/src/pages/MemberModifyPage.tsx +++ b/frontend/src/pages/MemberModifyPage.tsx @@ -62,11 +62,11 @@ export const MemberModifyPage = () => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('회원정보 수정을 다시 시도해주세요.'); + toast.error('회원정보 수정을 다시 시도해주세요.'); }, }); };