diff --git a/src/docs/asciidoc/api/review/review.adoc b/src/docs/asciidoc/api/review/review.adoc index f09237c..954148f 100644 --- a/src/docs/asciidoc/api/review/review.adoc +++ b/src/docs/asciidoc/api/review/review.adoc @@ -1,4 +1,18 @@ [[review-create]] + +=== 도서와 연관된 리뷰 조회 + +==== HTTP Request + +include::{snippets}/review/get-related-book/http-request.adoc[] +include::{snippets}/review/get-related-book/path-parameters.adoc[] +include::{snippets}/review/get-related-book/query-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/review/get-related-book/http-response.adoc[] +include::{snippets}/review/get-related-book/response-fields.adoc[] + === 한줄평 생성 ==== HTTP Request diff --git a/src/main/java/com/jisungin/api/review/ReviewController.java b/src/main/java/com/jisungin/api/review/ReviewController.java index 45c82f0..3df6491 100644 --- a/src/main/java/com/jisungin/api/review/ReviewController.java +++ b/src/main/java/com/jisungin/api/review/ReviewController.java @@ -3,25 +3,39 @@ import com.jisungin.api.ApiResponse; import com.jisungin.api.support.Auth; import com.jisungin.api.review.request.ReviewCreateRequest; +import com.jisungin.application.OffsetLimit; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.ReviewService; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -@RequestMapping("/v1/reviews") +@RequestMapping("/v1") @RequiredArgsConstructor @RestController public class ReviewController { private final ReviewService reviewService; - @PostMapping - public ApiResponse createReview(@Valid @RequestBody ReviewCreateRequest request, @Auth Long userId) { + @GetMapping("/books/{isbn}/reviews") + public ApiResponse> findBookReviews( + @PathVariable String isbn, + @RequestParam(required = false, defaultValue = "1") Integer page, + @RequestParam(required = false, defaultValue = "8") Integer size, + @RequestParam(required = false, defaultValue = "like") String order + ) { + return ApiResponse.ok(reviewService.findBookReviews(isbn, OffsetLimit.of(page, size, order))); + } + + @PostMapping("/reviews") + public ApiResponse createReview(@Valid @RequestBody ReviewCreateRequest request, + @Auth Long userId) { reviewService.createReview(request.toServiceRequest(), userId); return ApiResponse.ok(); } - @DeleteMapping("/{reviewId}") + @DeleteMapping("/reviews/{reviewId}") public ApiResponse deleteReview(@PathVariable Long reviewId, @Auth Long userId) { reviewService.deleteReview(reviewId, userId); return ApiResponse.ok(); diff --git a/src/main/java/com/jisungin/application/OffsetLimit.java b/src/main/java/com/jisungin/application/OffsetLimit.java new file mode 100644 index 0000000..4c9a7ee --- /dev/null +++ b/src/main/java/com/jisungin/application/OffsetLimit.java @@ -0,0 +1,42 @@ +package com.jisungin.application; + +import static java.lang.Math.*; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class OffsetLimit { + + private static final Integer MAX_SIZE = 2000; + private Integer offset; + private Integer limit; + private String order; + + @Builder + private OffsetLimit(Integer offset, Integer limit, String order) { + this.offset = offset; + this.limit = limit; + this.order = order; + } + + public static OffsetLimit of(Integer page, Integer size) { + return OffsetLimit.builder() + .offset(calculateOffset(page, size)) + .limit(size) + .build(); + } + + public static OffsetLimit of(Integer page, Integer size, String order) { + return OffsetLimit.builder() + .offset(calculateOffset(page, size)) + .limit(size) + .order(order) + .build(); + } + + private static Integer calculateOffset(Integer page, Integer size) { + return (max(1, page) - 1) * min(size, MAX_SIZE); + } + +} diff --git a/src/main/java/com/jisungin/application/SliceResponse.java b/src/main/java/com/jisungin/application/SliceResponse.java new file mode 100644 index 0000000..6b60ea4 --- /dev/null +++ b/src/main/java/com/jisungin/application/SliceResponse.java @@ -0,0 +1,54 @@ +package com.jisungin.application; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SliceResponse { + + private List content; + + private boolean hasContent; + + @JsonProperty("isFirst") + private boolean first; + + @JsonProperty("isLast") + private boolean last; + + private Integer number; + + private Integer size; + + @Builder + private SliceResponse(List content, boolean hasContent, boolean first, boolean last, Integer number, + Integer size) { + this.content = content; + this.hasContent = hasContent; + this.first = first; + this.last = last; + this.number = number; + this.size = size; + } + + public static SliceResponse of(List content, Integer offset, Integer limit, boolean hasNext) { + boolean hasContent = !content.isEmpty(); + boolean first = (offset == 0); + boolean last = !hasNext; + + Integer number = (offset / limit) + 1; + Integer size = content.size(); + + return SliceResponse.builder() + .content(content) + .hasContent(hasContent) + .first(first) + .last(last) + .number(number) + .size(size) + .build(); + } + +} diff --git a/src/main/java/com/jisungin/application/review/ReviewService.java b/src/main/java/com/jisungin/application/review/ReviewService.java index 3fc2adb..bceee3d 100644 --- a/src/main/java/com/jisungin/application/review/ReviewService.java +++ b/src/main/java/com/jisungin/application/review/ReviewService.java @@ -1,7 +1,14 @@ package com.jisungin.application.review; +import static com.jisungin.exception.ErrorCode.BOOK_NOT_FOUND; +import static com.jisungin.exception.ErrorCode.REVIEW_NOT_FOUND; +import static com.jisungin.exception.ErrorCode.UNAUTHORIZED_REQUEST; +import static com.jisungin.exception.ErrorCode.USER_NOT_FOUND; + +import com.jisungin.application.OffsetLimit; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.request.ReviewCreateServiceRequest; -import com.jisungin.application.review.response.ReviewResponse; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import com.jisungin.domain.book.Book; import com.jisungin.domain.book.repository.BookRepository; import com.jisungin.domain.review.Review; @@ -13,8 +20,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.jisungin.exception.ErrorCode.*; - @RequiredArgsConstructor @Transactional(readOnly = true) @Service @@ -24,6 +29,14 @@ public class ReviewService { private final UserRepository userRepository; private final BookRepository bookRepository; + public SliceResponse findBookReviews(String isbn, OffsetLimit offsetLimit) { + Book book = bookRepository.findById(isbn) + .orElseThrow(() -> new BusinessException(BOOK_NOT_FOUND)); + + return reviewRepository.findAllByBookId(book.getIsbn(), offsetLimit.getOffset(), offsetLimit.getLimit(), + offsetLimit.getOrder()); + } + @Transactional public void createReview(ReviewCreateServiceRequest request, Long userId) { User user = userRepository.findById(userId) diff --git a/src/main/java/com/jisungin/application/review/response/ReviewWithRatingResponse.java b/src/main/java/com/jisungin/application/review/response/ReviewWithRatingResponse.java new file mode 100644 index 0000000..d5a9fe7 --- /dev/null +++ b/src/main/java/com/jisungin/application/review/response/ReviewWithRatingResponse.java @@ -0,0 +1,46 @@ +package com.jisungin.application.review.response; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ReviewWithRatingResponse { + + private Long reviewId; + private Long ratingId; + private String username; + private String profileImage; + private String reviewContent; + private Double starRating; + private Long likeCount; + + @Builder + @QueryProjection + public ReviewWithRatingResponse(Long reviewId, Long ratingId, String username, String profileImage, + String reviewContent, Double starRating, Long likeCount) { + this.reviewId = reviewId; + this.ratingId = ratingId; + this.username = username; + this.profileImage = profileImage; + this.reviewContent = reviewContent; + this.starRating = starRating; + this.likeCount = likeCount; + } + + public static ReviewWithRatingResponse of(Long reviewId, Long ratingId, String username, String profileImage, + String reviewContent, Double starRating, Long likeCount) { + return ReviewWithRatingResponse.builder() + .reviewId(reviewId) + .ratingId(ratingId) + .username(username) + .profileImage(profileImage) + .reviewContent(reviewContent) + .starRating(starRating) + .likeCount(likeCount) + .build(); + } + +} diff --git a/src/main/java/com/jisungin/domain/review/ReviewOrderType.java b/src/main/java/com/jisungin/domain/review/ReviewOrderType.java new file mode 100644 index 0000000..60b1fe8 --- /dev/null +++ b/src/main/java/com/jisungin/domain/review/ReviewOrderType.java @@ -0,0 +1,57 @@ +package com.jisungin.domain.review; + +import static com.jisungin.domain.rating.QRating.rating1; +import static com.jisungin.domain.review.QReview.review; +import static com.jisungin.domain.reviewlike.QReviewLike.reviewLike; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQuery; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public enum ReviewOrderType { + + LIKE(() -> reviewLike.id.count().desc(), ReviewOrderType::leftJoinRating), + RECENT(review.createDateTime::desc, ReviewOrderType::leftJoinRating), + RATING_DESC(rating1.rating::desc, ReviewOrderType::joinRating), + RATING_ASC(rating1.rating::asc, ReviewOrderType::joinRating); + + private final Supplier> orderSpecifierSupplier; + private final Consumer> joinRatingStrategy; + + ReviewOrderType(Supplier> orderSpecifierSupplier, Consumer> joinRatingStrategy) { + this.orderSpecifierSupplier = orderSpecifierSupplier; + this.joinRatingStrategy = joinRatingStrategy; + } + + public OrderSpecifier getOrderSpecifier() { + return orderSpecifierSupplier.get(); + } + + public void applyJoinStrategy(JPAQuery query) { + joinRatingStrategy.accept(query); + applyCommonJoinConditions(query); + } + + public static ReviewOrderType fromString(String name) { + try { + return ReviewOrderType.valueOf(name.toUpperCase()); + } catch (IllegalArgumentException | NullPointerException e) { + return ReviewOrderType.LIKE; + } + } + + private static void joinRating(JPAQuery query) { + query.join(rating1); + } + + private static void leftJoinRating(JPAQuery query) { + query.leftJoin(rating1); + } + + private static void applyCommonJoinConditions(JPAQuery query) { + query.on(review.user.eq(rating1.user) + .and(review.book.eq(rating1.book))); + } + +} diff --git a/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryCustom.java b/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryCustom.java index 0d08733..e15019f 100644 --- a/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryCustom.java +++ b/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryCustom.java @@ -1,7 +1,9 @@ package com.jisungin.domain.review.repository; import com.jisungin.application.PageResponse; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.response.ReviewContentResponse; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import com.jisungin.domain.review.RatingOrderType; public interface ReviewRepositoryCustom { @@ -9,4 +11,6 @@ public interface ReviewRepositoryCustom { PageResponse findAllReviewContentOrderBy( Long userId, RatingOrderType orderType, int size, int offset); + SliceResponse findAllByBookId(String isbn, Integer offset, Integer limit, String order); + } diff --git a/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryImpl.java b/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryImpl.java index bd6006f..8dd1efb 100644 --- a/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryImpl.java +++ b/src/main/java/com/jisungin/domain/review/repository/ReviewRepositoryImpl.java @@ -1,20 +1,28 @@ package com.jisungin.domain.review.repository; import com.jisungin.application.PageResponse; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.response.QReviewContentResponse; +import com.jisungin.application.review.response.QReviewWithRatingResponse; import com.jisungin.application.review.response.ReviewContentResponse; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import com.jisungin.domain.review.RatingOrderType; +import com.jisungin.domain.review.ReviewOrderType; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.util.List; +import static com.jisungin.domain.book.QBook.*; import static com.jisungin.domain.rating.QRating.*; import static com.jisungin.domain.review.QReview.review; import static com.jisungin.domain.review.RatingOrderType.*; +import static com.jisungin.domain.reviewlike.QReviewLike.*; +import static com.jisungin.domain.user.QUser.*; @Slf4j @RequiredArgsConstructor @@ -36,6 +44,38 @@ public PageResponse findAllReviewContentOrderBy( .build(); } + @Override + public SliceResponse findAllByBookId(String isbn, Integer offset, Integer limit, + String order) { + ReviewOrderType orderType = ReviewOrderType.fromString(order); + + JPAQuery query = queryFactory. + select(new QReviewWithRatingResponse( + review.id.as("reviewId"), + rating1.id.as("ratingId"), + user.name.as("username"), + user.profileImage.as("profileImage"), + review.content.as("reviewContent"), + rating1.rating.as("starRating"), + reviewLike.id.count().as("likeCount") + )) + .from(review) + .join(review.user, user) + .join(review.book, book) + .leftJoin(reviewLike).on(review.eq(reviewLike.review)) + .where(book.isbn.eq(isbn)) + .groupBy(review.id) + .orderBy(orderType.getOrderSpecifier()) + .offset(offset) + .limit(limit + 1); + + orderType.applyJoinStrategy(query); + + List content = query.fetch(); + + return SliceResponse.of(content, offset, limit, hasNextPage(content, limit)); + } + private List getReviewContents( Long userId, RatingOrderType orderType, int size, int offset) { return queryFactory @@ -85,4 +125,14 @@ private BooleanExpression ratingCondition(Double rating) { return rating1.rating.eq(rating); } + private boolean hasNextPage(List content, int limit) { + boolean hasNext = content.size() > limit; + + if (hasNext) { + content.remove(limit); + } + + return hasNext; + } + } diff --git a/src/test/java/com/jisungin/api/review/ReviewControllerTest.java b/src/test/java/com/jisungin/api/review/ReviewControllerTest.java index 0fa9168..9a3c33c 100644 --- a/src/test/java/com/jisungin/api/review/ReviewControllerTest.java +++ b/src/test/java/com/jisungin/api/review/ReviewControllerTest.java @@ -1,6 +1,7 @@ package com.jisungin.api.review; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -14,6 +15,20 @@ class ReviewControllerTest extends ControllerTestSupport { + @DisplayName("도서와 연관된 리뷰를 조회한다.") + void findBookReviews() throws Exception { + // given + String isbn = "000000000000"; + + // when // then + mockMvc.perform(get("/v1/books/{isbn}/reviews", isbn) + .param("page", "1") + .param("size", "5") + .param("order", "like") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + @DisplayName("유저가 리뷰를 등록한다.") @Test void createReview() throws Exception { diff --git a/src/test/java/com/jisungin/application/review/ReviewServiceTest.java b/src/test/java/com/jisungin/application/review/ReviewServiceTest.java index f238212..b266184 100644 --- a/src/test/java/com/jisungin/application/review/ReviewServiceTest.java +++ b/src/test/java/com/jisungin/application/review/ReviewServiceTest.java @@ -1,9 +1,14 @@ package com.jisungin.application.review; import com.jisungin.ServiceTestSupport; +import com.jisungin.application.OffsetLimit; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.request.ReviewCreateServiceRequest; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import com.jisungin.domain.book.Book; import com.jisungin.domain.book.repository.BookRepository; +import com.jisungin.domain.reviewlike.ReviewLike; +import com.jisungin.domain.reviewlike.repository.ReviewLikeRepository; import com.jisungin.domain.user.OauthId; import com.jisungin.domain.user.OauthType; import com.jisungin.domain.review.Review; @@ -11,6 +16,7 @@ import com.jisungin.domain.user.User; import com.jisungin.domain.user.repository.UserRepository; import com.jisungin.exception.BusinessException; +import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,6 +32,9 @@ class ReviewServiceTest extends ServiceTestSupport { @Autowired private ReviewRepository reviewRepository; + @Autowired + private ReviewLikeRepository reviewLikeRepository; + @Autowired private UserRepository userRepository; @@ -37,11 +46,52 @@ class ReviewServiceTest extends ServiceTestSupport { @AfterEach void tearDown() { + reviewLikeRepository.deleteAllInBatch(); reviewRepository.deleteAllInBatch(); bookRepository.deleteAllInBatch(); userRepository.deleteAllInBatch(); } + @DisplayName("도서와 연관된 리뷰를 조회한다.") + @Test + void findBookReviews() { + // given + OffsetLimit offsetLimit = OffsetLimit.of(1, 5, "like"); + + Book book = bookRepository.save(createBook()); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviews(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewService.findBookReviews(book.getIsbn(), + offsetLimit); + + // then + assertThat(result.isHasContent()).isTrue(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + assertThat(result.getNumber()).isEqualTo(1L); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getContent()).hasSize(5) + .extracting("likeCount") + .containsExactly(20L, 0L, 0L, 0L, 0L); + } + + @DisplayName("도서와 연관된 리뷰 조회 시 도서가 존재해야 한다.") + @Test + void findBookReviewsWithoutBook() { + // given + String invalidBookIsbn = "0000X"; + OffsetLimit offsetLimit = OffsetLimit.of(1, 10); + + // when // then + assertThatThrownBy(() -> reviewService.findBookReviews(invalidBookIsbn, offsetLimit)) + .isInstanceOf(BusinessException.class) + .hasMessage("책을 찾을 수 없습니다."); + } + @DisplayName("유저가 리뷰를 등록한다.") @Test void createReview() { @@ -133,6 +183,20 @@ void deleteReviewWithAnotherUser() { .hasMessage("권한이 없는 사용자입니다."); } + private static ReviewLike createReviewLike(User user, Review review) { + return ReviewLike.builder() + .user(user) + .review(review) + .build(); + } + + private static List createReviewLikesForReviewWithUsers(List users, Review review) { + return IntStream.range(0, 20) + .mapToObj(i -> createReviewLike(users.get(i), review)) + .toList(); + } + + private static Review createReview(User user, Book book) { return Review.builder() .user(user) @@ -141,6 +205,12 @@ private static Review createReview(User user, Book book) { .build(); } + private static List createReviews(List users, Book book) { + return IntStream.range(0, 20) + .mapToObj(i -> createReview(users.get(i), book)) + .toList(); + } + private static User createUser(String oauthId) { return User.builder() .name("김도형") @@ -154,6 +224,12 @@ private static User createUser(String oauthId) { .build(); } + private static List createUsers() { + return IntStream.range(0, 20) + .mapToObj(i -> createUser(String.valueOf(i))) + .toList(); + } + private static Book createBook() { return Book.builder() .title("제목") diff --git a/src/test/java/com/jisungin/docs/review/ReviewControllerDocsTest.java b/src/test/java/com/jisungin/docs/review/ReviewControllerDocsTest.java index 6097f84..16659bf 100644 --- a/src/test/java/com/jisungin/docs/review/ReviewControllerDocsTest.java +++ b/src/test/java/com/jisungin/docs/review/ReviewControllerDocsTest.java @@ -1,25 +1,39 @@ package com.jisungin.docs.review; -import com.jisungin.api.review.ReviewController; -import com.jisungin.api.review.request.ReviewCreateRequest; -import com.jisungin.application.review.ReviewService; -import com.jisungin.docs.RestDocsSupport; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.jisungin.api.review.ReviewController; +import com.jisungin.api.review.request.ReviewCreateRequest; +import com.jisungin.application.OffsetLimit; +import com.jisungin.application.SliceResponse; +import com.jisungin.application.review.ReviewService; +import com.jisungin.application.review.response.ReviewWithRatingResponse; +import com.jisungin.docs.RestDocsSupport; +import java.util.List; +import java.util.stream.LongStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + public class ReviewControllerDocsTest extends RestDocsSupport { private final ReviewService reviewService = mock(ReviewService.class); @@ -29,6 +43,91 @@ protected Object initController() { return new ReviewController(reviewService); } + @DisplayName("도서와 연관된 리뷰 조회 API") + @Test + void findBookReviews() throws Exception { + // given + String isbn = "000000000001"; + + List response = LongStream.rangeClosed(1, 10) + .mapToObj(i -> ReviewWithRatingResponse.builder() + .reviewId(i) + .ratingId(i) + .username("작성자 " + i) + .profileImage("http://www.profile-image.com/" + i) + .reviewContent("리뷰 내용 " + i) + .starRating(3.5) + .likeCount(20L) + .build()) + .toList(); + + given(reviewService.findBookReviews(anyString(), any(OffsetLimit.class))) + .willReturn(SliceResponse.of(response, 0, 10, true)); + + // when // then + mockMvc.perform( + get("/v1/books/{isbn}/reviews", isbn) + .param("page", "1") + .param("size", "10") + .param("order", "like") + .accept(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("review/get-related-book", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("isbn") + .description("도서 ISBN") + ), + queryParameters( + parameterWithName("page") + .description("페이지 번호"), + parameterWithName("size") + .description("페이지 사이즈"), + parameterWithName("order").description( + "정렬 기준: like(좋아요 순), recent(최신순), rating_desc(별점 높은 순), rating_asc(별점 낮은 순)") + ), + responseFields( + fieldWithPath("code").type(JsonFieldType.NUMBER) + .description("코드"), + fieldWithPath("status").type(JsonFieldType.STRING) + .description("상태"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("메세지"), + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("응답 데이터"), + fieldWithPath("data.hasContent").type(JsonFieldType.BOOLEAN) + .description("데이터 존재 여부"), + fieldWithPath("data.number").type(JsonFieldType.NUMBER) + .description("현재 페이지 번호"), + fieldWithPath("data.size").type(JsonFieldType.NUMBER) + .description("현재 페이지 사이즈"), + fieldWithPath("data.isFirst").type(JsonFieldType.BOOLEAN) + .description("첫 번째 페이지 여부"), + fieldWithPath("data.isLast").type(JsonFieldType.BOOLEAN) + .description("마지막 페에지 여부"), + fieldWithPath("data.content[]").type(JsonFieldType.ARRAY) + .description("슬라이싱 데이터"), + fieldWithPath("data.content[].reviewId").type(JsonFieldType.NUMBER) + .description("리뷰 ID"), + fieldWithPath("data.content[].ratingId").type(JsonFieldType.NUMBER) + .description("별점 ID"), + fieldWithPath("data.content[].username").type(JsonFieldType.STRING) + .description("작성자 이름"), + fieldWithPath("data.content[].profileImage").type(JsonFieldType.STRING) + .description("작성자 프로필 이미지 URL"), + fieldWithPath("data.content[].reviewContent").type(JsonFieldType.STRING) + .description("리뷰 작성 내용"), + fieldWithPath("data.content[].starRating").type(JsonFieldType.NUMBER) + .description("별점 점수"), + fieldWithPath("data.content[].likeCount").type(JsonFieldType.NUMBER) + .description("좋아요 개수") + )) + ); + } + @DisplayName("한줄평을 생성하는 API") @Test void createReview() throws Exception { diff --git a/src/test/java/com/jisungin/domain/review/repository/ReviewRepositoryTest.java b/src/test/java/com/jisungin/domain/review/repository/ReviewRepositoryTest.java index 27d160f..bbb2638 100644 --- a/src/test/java/com/jisungin/domain/review/repository/ReviewRepositoryTest.java +++ b/src/test/java/com/jisungin/domain/review/repository/ReviewRepositoryTest.java @@ -6,7 +6,9 @@ import com.jisungin.RepositoryTestSupport; import com.jisungin.application.PageResponse; +import com.jisungin.application.SliceResponse; import com.jisungin.application.review.response.ReviewContentResponse; +import com.jisungin.application.review.response.ReviewWithRatingResponse; import com.jisungin.domain.book.Book; import com.jisungin.domain.book.repository.BookRepository; import com.jisungin.domain.rating.Rating; @@ -60,8 +62,8 @@ void getReviewContentOrderByRatingAsc() { User user1 = userRepository.save(createUser("1")); User user2 = userRepository.save(createUser("2")); List books = bookRepository.saveAll(createBooks()); - List reviews = reviewRepository.saveAll(createReviews(user1, books)); - List ratings = ratingRepository.saveAll(createRatings(user1, books)); + List reviews = reviewRepository.saveAll(createReviewsForUser(user1, books)); + List ratings = ratingRepository.saveAll(createRatingsForUserWithBooks(user1, books)); List reviewLikesWithUser1 = reviewLikeRepository.saveAll(createReviewLikes(user1, reviews)); List reviewLikesWithUser2 = reviewLikeRepository.saveAll(createReviewLikes(user2, reviews)); @@ -82,6 +84,162 @@ void getReviewContentOrderByRatingAsc() { ); } + @DisplayName("도서와 연관된 리뷰를 조회한다.") + @Test + void findAllByBookById() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List ratings = ratingRepository.saveAll(createRatingsForBookWithUsers(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "like"); + + // then + assertThat(result.isHasContent()).isTrue(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getContent()).hasSize(5) + .extracting("likeCount") + .containsExactly(20L, 0L, 0L, 0L, 0L); + } + + @DisplayName("도서와 연관된 리뷰를 최근 생성된 순으로 조회한다.") + @Test + void findAllByBookIdOrderByRecent() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List ratings = ratingRepository.saveAll(createRatingsForBookWithUsers(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "recent"); + + // then + assertThat(result.isHasContent()).isTrue(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getContent()).hasSize(5) + .extracting("reviewId", "ratingId") + .contains( + tuple(reviews.get(reviews.size() - 1).getId(), ratings.get(ratings.size() - 1).getId()), + tuple(reviews.get(reviews.size() - 2).getId(), ratings.get(ratings.size() - 2).getId()), + tuple(reviews.get(reviews.size() - 3).getId(), ratings.get(ratings.size() - 3).getId()), + tuple(reviews.get(reviews.size() - 4).getId(), ratings.get(ratings.size() - 4).getId()), + tuple(reviews.get(reviews.size() - 5).getId(), ratings.get(ratings.size() - 5).getId()) + ); + } + + @DisplayName("도서와 연관된 리뷰를 별점 많은 순으로 조회한다.") + @Test + public void findAllBookIdOrderByRatingDesc() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List ratings = ratingRepository.saveAll(createRatingsForBookWithUsers(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "rating_desc"); + + // then + assertThat(result.isHasContent()).isTrue(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getContent()).hasSize(5) + .extracting("starRating") + .containsExactly(5.0, 5.0, 5.0, 5.0, 4.0); + } + + @DisplayName("도서와 연관된 리뷰를 별점 높은 순으로 조회 시 별점이 없는 리뷰는 조회되지 않는다.") + @Test + public void findAllBookIdOrderByRatingDescWithoutRating() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "rating_desc"); + + // then + assertThat(result.isHasContent()).isFalse(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isTrue(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + + @DisplayName("도서와 연관된 리뷰를 별점 낮은 순으로 조회한다.") + @Test + public void findAllBookIdOrderByRatingAsc() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List ratings = ratingRepository.saveAll(createRatingsForBookWithUsers(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "rating_asc"); + + // then + assertThat(result.isHasContent()).isTrue(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(5); + assertThat(result.getContent()).hasSize(5) + .extracting("starRating") + .containsExactly(1.0, 1.0, 1.0, 1.0, 2.0); + } + + @DisplayName("도서와 연관된 리뷰를 별점 낮은 순으로 조회 시 별점이 없는 경우 조회되지 않는다.") + @Test + public void findAllBookIdOrderByRatingAscWithoutRating() { + // given + Book book = bookRepository.save(createBook("도서 제목", "도서 내용", "00001")); + List users = userRepository.saveAll(createUsers()); + List reviews = reviewRepository.saveAll(createReviewsForBook(users, book)); + List reviewLikes = reviewLikeRepository.saveAll( + createReviewLikesForReviewWithUsers(users, reviews.get(0))); + + // when + SliceResponse result = reviewRepository.findAllByBookId(book.getIsbn(), 0, 5, + "rating_asc"); + + // then + assertThat(result.isHasContent()).isFalse(); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isTrue(); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.getSize()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + private static List createBooks() { return IntStream.rangeClosed(1, 20) .mapToObj(i -> createBook( @@ -101,16 +259,22 @@ private static Book createBook(String title, String content, String isbn) { .build(); } - private static List createReviews(User user, List books) { + private static List createReviewsForUser(User user, List books) { return IntStream.range(0, 20) .mapToObj(i -> { double rating = i % 5 + 1.0; // 1.0, 2.0, 3.0, 4.0, 5.0이 순환되도록 설정 - return createReview(user, books.get(i), rating); // Review 객체 생성 + return createReview(user, books.get(i)); // Review 객체 생성 }) .collect(Collectors.toList()); } - private static Review createReview(User user, Book book, Double rating) { + private static List createReviewsForBook(List users, Book book) { + return IntStream.range(0, 20) + .mapToObj(i -> createReview(users.get(i), book)) + .toList(); + } + + private static Review createReview(User user, Book book) { return Review.builder() .user(user) .book(book) @@ -124,6 +288,13 @@ private static List createReviewLikes(User user, List review .toList(); } + private List createReviewLikesForReviewWithUsers(List users, Review review) { + return users.stream() + .map(user -> createReviewLike(user, review)) + .toList(); + } + + private static ReviewLike createReviewLike(User user, Review review) { return ReviewLike.builder() .user(user) @@ -144,7 +315,26 @@ private static User createUser(String oauthId) { .build(); } - private static List createRatings(User user, List books) { + private static User createUserWithId(int id) { + return User.builder() + .name("사용자" + id) + .profileImage("www.profileImage.com/" + id) + .oauthId( + OauthId.builder() + .oauthId(String.valueOf(id)) + .oauthType(OauthType.KAKAO) + .build() + ) + .build(); + } + + private static List createUsers() { + return IntStream.range(0, 20) + .mapToObj(i -> createUserWithId(i)) + .toList(); + } + + private static List createRatingsForUserWithBooks(User user, List books) { return IntStream.range(0, 20) .mapToObj(i -> { double rating = i % 5 + 1.0; @@ -153,6 +343,15 @@ private static List createRatings(User user, List books) { .collect(Collectors.toList()); } + private static List createRatingsForBookWithUsers(List users, Book book) { + return IntStream.range(0, 20) + .mapToObj(i -> { + double rating = i % 5 + 1.0; + return createRating(users.get(i), book, rating); + }) + .toList(); + } + private static Rating createRating(User user, Book book, Double rating) { return Rating.builder() .user(user)