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()