diff --git a/backend/src/docs/asciidoc/review-list.adoc b/backend/src/docs/asciidoc/review-list.adoc index f7aed34b8..2efac9bcb 100644 --- a/backend/src/docs/asciidoc/review-list.adoc +++ b/backend/src/docs/asciidoc/review-list.adoc @@ -2,6 +2,6 @@ operation::received-reviews[snippets="curl-request,request-headers,http-response,response-fields"] -==== 자신이 받은 리뷰 목록 조회 (세션 사용) +==== 자신이 받은 리뷰 목록 조회 -operation::received-review-list-with-session[snippets="curl-request,request-cookies,http-response,response-fields"] +operation::received-review-list-with-pagination[snippets="curl-request,request-cookies,query-parameters,http-response,response-fields"] diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index 532b6a703..c1485f678 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.SessionAttribute; import reviewme.review.service.ReviewDetailLookupService; @@ -35,9 +36,12 @@ public ResponseEntity createReview(@Valid @RequestBody ReviewRegisterReque @GetMapping("/v2/reviews") public ResponseEntity findReceivedReviews( + @RequestParam(required = false) Long lastReviewId, + @RequestParam(required = false) Integer size, @SessionAttribute("reviewRequestCode") String reviewRequestCode ) { - ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews(reviewRequestCode); + ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews( + lastReviewId, size, reviewRequestCode); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index 40acd6167..7965e4ce9 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -17,5 +17,14 @@ public interface ReviewRepository extends JpaRepository { """, nativeQuery = true) List findAllByGroupId(long reviewGroupId); + @Query(value = """ + SELECT r.* FROM review r + WHERE r.review_group_id = :reviewGroupId + AND (:lastReviewId IS NULL OR r.id < :lastReviewId) + ORDER BY r.created_at DESC + LIMIT :limit + """, nativeQuery = true) + List findByReviewGroupIdWithLimit(long reviewGroupId, Long lastReviewId, int limit); + Optional findByIdAndReviewGroupId(long reviewId, long reviewGroupId); } diff --git a/backend/src/main/java/reviewme/review/service/PageSize.java b/backend/src/main/java/reviewme/review/service/PageSize.java new file mode 100644 index 000000000..7c8d69b2f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/PageSize.java @@ -0,0 +1,20 @@ +package reviewme.review.service; + +import lombok.Getter; + +@Getter +public class PageSize { + + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 50; + + private final int size; + + PageSize(Integer size) { + if (size == null || size < 1 || size > MAX_SIZE) { + this.size = DEFAULT_SIZE; + return; + } + this.size = size; + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java index b64604814..f48f8bbeb 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java @@ -19,13 +19,23 @@ public class ReviewListLookupService { private final ReviewListMapper reviewListMapper; @Transactional(readOnly = true) - public ReceivedReviewsResponse getReceivedReviews(String reviewRequestCode) { + public ReceivedReviewsResponse getReceivedReviews(Long lastReviewId, Integer size, String reviewRequestCode) { ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); - List reviewGroupResponse = reviewListMapper.mapToReviewList(reviewGroup); + PageSize pageSize = new PageSize(size); + List reviewListResponse + = reviewListMapper.mapToReviewList(reviewGroup, lastReviewId, pageSize.getSize()); + long newLastReviewId = calculateLastReviewId(reviewListResponse); return new ReceivedReviewsResponse( - reviewGroup.getReviewee(), reviewGroup.getProjectName(), reviewGroupResponse + reviewGroup.getReviewee(), reviewGroup.getProjectName(), newLastReviewId, reviewListResponse ); } + + private long calculateLastReviewId(List elements) { + if (elements.isEmpty()) { + return 0; + } + return elements.get(elements.size() - 1).reviewId(); + } } diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java index e15cf92c6..d96b0d3f4 100644 --- a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java @@ -5,6 +5,7 @@ public record ReceivedReviewsResponse( String revieweeName, String projectName, + long lastReviewId, List reviews ) { } diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java index 1400c6293..4627c1bf8 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewListMapper.java @@ -22,9 +22,9 @@ public class ReviewListMapper { private final ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); - public List mapToReviewList(ReviewGroup reviewGroup) { + public List mapToReviewList(ReviewGroup reviewGroup, Long lastReviewId, int size) { List categoryOptionIds = optionItemRepository.findAllByOptionType(OptionType.CATEGORY); - return reviewRepository.findAllByGroupId(reviewGroup.getId()) + return reviewRepository.findByReviewGroupIdWithLimit(reviewGroup.getId(), lastReviewId, size) .stream() .map(review -> mapToReviewListElementResponse(review, categoryOptionIds)) .toList(); @@ -43,7 +43,7 @@ private ReviewListElementResponse mapToReviewListElementResponse(Review review, } private List mapToCategoryOptionResponse(Review review, - List categoryOptionItems) { + List categoryOptionItems) { Set checkBoxOptionIds = review.getAllCheckBoxOptionIds(); return categoryOptionItems.stream() .filter(optionItem -> checkBoxOptionIds.contains(optionItem.getId())) diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java index 5e895e58c..0d53801a1 100644 --- a/backend/src/test/java/reviewme/api/ReviewApiTest.java +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -1,6 +1,7 @@ package reviewme.api; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; @@ -11,6 +12,7 @@ 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 java.time.LocalDate; import java.util.List; @@ -156,24 +158,32 @@ class ReviewApiTest extends ApiTest { } @Test - void 세션으로_자신이_받은_리뷰_목록을_조회한다() { + void 자신이_받은_리뷰_목록을_조회한다() { List receivedReviews = List.of( new ReviewListElementResponse(1L, LocalDate.of(2024, 8, 1), "(리뷰 미리보기 1)", List.of(new ReviewCategoryResponse(1L, "카테고리 1"))), new ReviewListElementResponse(2L, LocalDate.of(2024, 8, 2), "(리뷰 미리보기 2)", List.of(new ReviewCategoryResponse(2L, "카테고리 2"))) ); - ReceivedReviewsResponse response = new ReceivedReviewsResponse("아루", "리뷰미", receivedReviews); - BDDMockito.given(reviewListLookupService.getReceivedReviews(anyString())) + ReceivedReviewsResponse response = new ReceivedReviewsResponse( + "아루3", "리뷰미", 1L, receivedReviews); + BDDMockito.given(reviewListLookupService.getReceivedReviews(anyLong(), anyInt(), anyString())) .willReturn(response); CookieDescriptor[] cookieDescriptors = { cookieWithName("JSESSIONID").description("세션 쿠키") }; + ParameterDescriptor[] queryParameter = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드"), + parameterWithName("lastReviewId").description("페이지의 마지막 리뷰 ID - 기본으로 최신순 첫번째 페이지 응답"), + parameterWithName("size").description("페이지의 크기 - 기본으로 5개씩 응답") + }; + FieldDescriptor[] responseFieldDescriptors = { fieldWithPath("revieweeName").description("리뷰이 이름"), fieldWithPath("projectName").description("프로젝트 이름"), + fieldWithPath("lastReviewId").description("페이지의 마지막 리뷰 ID"), fieldWithPath("reviews[]").description("리뷰 목록"), fieldWithPath("reviews[].reviewId").description("리뷰 ID"), @@ -186,13 +196,17 @@ class ReviewApiTest extends ApiTest { }; RestDocumentationResultHandler handler = document( - "received-review-list-with-session", + "received-review-list-with-pagination", requestCookies(cookieDescriptors), + queryParameters(queryParameter), responseFields(responseFieldDescriptors) ); givenWithSpec().log().all() .cookie("JSESSIONID", "ASVNE1VAKDNV4") + .queryParam("reviewRequestCode", "hello!!") + .queryParam("lastReviewId", "2") + .queryParam("size", "5") .when().get("/v2/reviews") .then().log().all() .apply(handler) diff --git a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java index 3340592a1..d51b4ddf7 100644 --- a/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java +++ b/backend/src/test/java/reviewme/review/repository/ReviewRepositoryTest.java @@ -1,13 +1,13 @@ package reviewme.review.repository; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; import static reviewme.fixture.QuestionFixture.서술형_필수_질문; import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; import static reviewme.fixture.SectionFixture.항상_보이는_섹션; import static reviewme.fixture.TemplateFixture.템플릿; import java.util.List; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -59,4 +59,98 @@ class ReviewRepositoryTest { // then assertThat(actual).containsExactly(review2, review1); } + + @Nested + class 리뷰그룹_아이디에_해당하는_리뷰를_생성일_기준_내림차순으로_페이징하여_불러온다 { + + private final Question question = questionRepository.save(서술형_필수_질문()); + private final Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + private final Template template = templateRepository.save(템플릿(List.of(section.getId()))); + private final ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + private final Review review1 = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), null, null)); + private final Review review2 = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), null, null)); + private final Review review3 = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), null, null)); + + @Test + void 페이징_크기보다_적은_수의_리뷰가_등록되었으면_그_크기만큼의_리뷰만_반환한다() { + // given + int limit = 5; + long lastReviewId = Long.MAX_VALUE; + + // when + List actual = reviewRepository.findByReviewGroupIdWithLimit( + reviewGroup.getId(), lastReviewId, limit); + + // then + assertThat(actual) + .hasSize(3) + .containsExactly(review3, review2, review1); + } + + @Test + void 페이징_크기보다_큰_수의_리뷰가_등록되었으면_페이징_크기만큼의_리뷰를_반환한다() { + // given + int limit = 2; + long lastReviewId = Long.MAX_VALUE; + + // when + List actual = reviewRepository.findByReviewGroupIdWithLimit( + reviewGroup.getId(), lastReviewId, limit); + + // then + assertThat(actual) + .hasSize(2) + .containsExactly(review3, review2); + } + + @Test + void 마지막_리뷰_아이디가_주어지지_않으면_가장_최신순으로_리뷰를_반환한다() { + // given + int limit = 5; + Long lastReviewId = null; + + // when + List actual = reviewRepository.findByReviewGroupIdWithLimit( + reviewGroup.getId(), lastReviewId, limit); + + // then + assertThat(actual) + .hasSize(3) + .containsExactly(review3, review2, review1); + } + + @Test + void 마지막_리뷰_아이디를_기준으로_그보다_전에_적힌_리뷰를_반환한다() { + // given + int limit = 5; + long lastReviewId = review3.getId(); + + // when + List actual = reviewRepository.findByReviewGroupIdWithLimit( + reviewGroup.getId(), lastReviewId, limit); + + // then + assertThat(actual) + .hasSize(2) + .containsExactly(review2, review1); + } + + @Test + void 마지막으로_온_리뷰_전에_작성된_리뷰가_없으면_빈_리스트를_반환한다() { + // given + int limit = 5; + long lastReviewId = review1.getId(); + + // when + List actual = reviewRepository.findByReviewGroupIdWithLimit( + reviewGroup.getId(), lastReviewId, limit); + + // then + assertThat(actual).isEmpty(); + } + } } diff --git a/backend/src/test/java/reviewme/review/service/PageSizeTest.java b/backend/src/test/java/reviewme/review/service/PageSizeTest.java new file mode 100644 index 000000000..c3bc4bae4 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/PageSizeTest.java @@ -0,0 +1,47 @@ +package reviewme.review.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PageSizeTest { + + @Test + void 유효한_값이_들어오면_그_값을_설정한다() { + // given + int size = 50; + + // when + PageSize pageSize = new PageSize(size); + + // then + assertEquals(size, pageSize.getSize()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 51}) + void 유효한_범위_외의_값이_들어오면_기본값으로_설정한다(Integer size) { + // given + int defaultSize = 10; + + // when + PageSize pageSize = new PageSize(size); + + // then + assertEquals(defaultSize, pageSize.getSize()); + } + + @Test + void null이_들어오면_기본값으로_설정한다() { + // given + int defaultSize = 10; + + // when + PageSize pageSize = new PageSize(null); + + // then + assertEquals(defaultSize, pageSize.getSize()); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java index 64a1af50e..9146bb184 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewListLookupServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.OptionGroupFixture.선택지_그룹; import static reviewme.fixture.OptionItemFixture.선택지; import static reviewme.fixture.QuestionFixture.선택형_필수_질문; @@ -61,7 +62,7 @@ class ReviewListLookupServiceTest { @Test void 리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { - assertThatThrownBy(() -> reviewListLookupService.getReceivedReviews("abc")) + assertThatThrownBy(() -> reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 5, "abc")) .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); } @@ -89,9 +90,46 @@ class ReviewListLookupServiceTest { reviewRepository.saveAll(List.of(review1, review2)); // when - ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews(reviewRequestCode); + ReceivedReviewsResponse response = reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 5, reviewRequestCode); // then assertThat(response.reviews()).hasSize(2); } + + @Test + void 내가_받은_리뷰_목록을_페이지네이션을_적용하여_반환한다() { + // given - 리뷰 그룹 저장 + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹(reviewRequestCode, groupAccessCode)); + + // given - 질문 저장 + Question question = questionRepository.save(선택형_필수_질문()); + + // given - 섹션, 템플릿 저장 + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + // given - 리뷰 답변 저장 + TextAnswer textAnswer = new TextAnswer(question.getId(), "텍스트형 응답"); + Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer), List.of()); + Review review2 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer), List.of()); + Review review3 = new Review(template.getId(), reviewGroup.getId(), List.of(textAnswer), List.of()); + reviewRepository.saveAll(List.of(review1, review2, review3)); + + // when + ReceivedReviewsResponse response + = reviewListLookupService.getReceivedReviews(Long.MAX_VALUE, 2, reviewRequestCode); + + // then + assertAll( + () -> assertThat(response.reviews()) + .hasSize(2) + .extracting("reviewId") + .containsExactly(review3.getId(), review2.getId()), + () -> + assertThat(response.lastReviewId()) + .isEqualTo(review2.getId()) + ); + } }