diff --git a/src/main/java/com/ject/studytrip/global/common/factory/CacheKeyFactory.java b/src/main/java/com/ject/studytrip/global/common/factory/CacheKeyFactory.java index fc0682c..18b626a 100644 --- a/src/main/java/com/ject/studytrip/global/common/factory/CacheKeyFactory.java +++ b/src/main/java/com/ject/studytrip/global/common/factory/CacheKeyFactory.java @@ -33,7 +33,16 @@ public static String dailyGoal(Long memberId, Long tripId, Long dailyGoalId) { return "member:" + memberId + ":trip:" + tripId + ":dailyGoal:" + dailyGoalId; } - public static String studyLogs(Long memberId, Long tripId, int page, int size) { - return "member:" + memberId + ":trip:" + tripId + ":page:" + page + ":size:" + size; + public static String studyLogs(Long memberId, Long tripId, int page, int size, String order) { + return "member:" + + memberId + + ":trip:" + + tripId + + ":page:" + + page + + ":size:" + + size + + ":order:" + + order.toLowerCase(); } } diff --git a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java index 3999c24..50973fb 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java +++ b/src/main/java/com/ject/studytrip/studylog/application/facade/StudyLogFacade.java @@ -14,10 +14,7 @@ import com.ject.studytrip.pomodoro.domain.model.Pomodoro; import com.ject.studytrip.stamp.application.service.StampCommandService; import com.ject.studytrip.stamp.domain.model.Stamp; -import com.ject.studytrip.studylog.application.dto.PresignedStudyLogImageInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogDetail; -import com.ject.studytrip.studylog.application.dto.StudyLogInfo; -import com.ject.studytrip.studylog.application.dto.StudyLogSliceInfo; +import com.ject.studytrip.studylog.application.dto.*; import com.ject.studytrip.studylog.application.service.*; import com.ject.studytrip.studylog.domain.model.StudyLog; import com.ject.studytrip.studylog.domain.model.StudyLogDailyMission; @@ -90,15 +87,16 @@ public StudyLogInfo createStudyLog( @Cacheable( cacheNames = STUDY_LOGS, key = - "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).studyLogs(#memberId, #tripId, #page, #size)") + "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).studyLogs(#memberId, #tripId, #page, #size, #order)") @Transactional(readOnly = true) - public StudyLogSliceInfo getStudyLogsByTrip(Long memberId, Long tripId, int page, int size) { + public StudyLogSliceInfo getStudyLogsByTrip( + Long memberId, Long tripId, int page, int size, String order) { // 1. 유효성 검증 및 엔티티 조회 Trip trip = tripQueryService.getValidTrip(memberId, tripId); // 2. 페이징된 학습 로그 목록 조회 Slice studyLogSlice = - studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size); + studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size, order); // 3. 학습 로그 상세 정보 구성 return buildStudyLogDetailsSlice(studyLogSlice); diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java index 58dcb14..5a60e00 100644 --- a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogQueryService.java @@ -22,9 +22,9 @@ public long getActiveStudyLogCountByMemberId(Long memberId) { return studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId); } - public Slice getStudyLogsSliceByTripId(Long tripId, int page, int size) { - return studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc( - tripId, PageRequest.of(page, size)); + public Slice getStudyLogsSliceByTripId( + Long tripId, int page, int size, String order) { + return studyLogQueryRepository.findSliceByTripId(tripId, PageRequest.of(page, size), order); } public StudyLog getValidStudyLog(Long studyLogId) { diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java index c579c27..566a8b2 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java @@ -7,7 +7,7 @@ public interface StudyLogQueryRepository { long countActiveStudyLogsByMemberId(Long memberId); - Slice findSliceByTripIdOrderByCreatedAtDesc(Long tripId, Pageable pageable); + Slice findSliceByTripId(Long tripId, Pageable pageable, String order); long deleteAllByDeletedAtIsNotNull(); diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java index b59eb07..dc43d38 100644 --- a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java @@ -6,6 +6,7 @@ import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; import com.ject.studytrip.trip.domain.model.QDailyGoal; import com.ject.studytrip.trip.domain.model.QTripReportStudyLog; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; @@ -38,7 +39,7 @@ public long countActiveStudyLogsByMemberId(Long memberId) { } @Override - public Slice findSliceByTripIdOrderByCreatedAtDesc(Long tripId, Pageable pageable) { + public Slice findSliceByTripId(Long tripId, Pageable pageable, String order) { List content = queryFactory .selectFrom(studyLog) @@ -46,7 +47,7 @@ public Slice findSliceByTripIdOrderByCreatedAtDesc(Long tripId, Pageab .where(dailyGoal.trip.id.eq(tripId), dailyGoal.deletedAt.isNull()) .offset(pageable.getOffset()) .limit(pageable.getPageSize() + 1) - .orderBy(studyLog.createdAt.desc()) + .orderBy(orderSpecifiers(order)) .fetch(); List result = content; @@ -124,4 +125,10 @@ public Slice findSliceByTripReportIdOrderByCreatedAtDesc( return new SliceImpl<>(result, pageable, hasNext); } + + private OrderSpecifier[] orderSpecifiers(String order) { + return (order.equalsIgnoreCase("OLDEST")) + ? new OrderSpecifier[] {studyLog.createdAt.asc(), studyLog.id.asc()} + : new OrderSpecifier[] {studyLog.createdAt.desc(), studyLog.id.desc()}; + } } diff --git a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java index 87743be..3753e3b 100644 --- a/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java +++ b/src/main/java/com/ject/studytrip/studylog/presentation/controller/StudyLogController.java @@ -17,6 +17,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -49,15 +50,22 @@ public ResponseEntity createStudyLog( @Operation( summary = "여행의 학습 로그 목록 조회", - description = "특정 여행의 학습 로그 목록을 조회하는 API 입니다. 슬라이스를 적용하고 최신순으로 정렬합니다.") + description = + "특정 여행의 학습 로그 목록을 조회하는 API 입니다. 슬라이스를 적용하고 정렬 옵션 LATEST(최신순)/OLDEST(과거순)을 적용합니다.") @GetMapping("/api/trips/{tripId}/study-logs") public ResponseEntity loadStudyLogsByTrip( @AuthenticationPrincipal String memberId, @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, - @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size) { + @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size, + @RequestParam(name = "order", defaultValue = "LATEST") + @Pattern( + regexp = "LATEST|OLDEST", + message = "정렬은 최신순(LATEST) 또는 과거순(OLDEST)만 허용됩니다.") + String order) { StudyLogSliceInfo result = - studyLogFacade.getStudyLogsByTrip(Long.valueOf(memberId), tripId, page, size); + studyLogFacade.getStudyLogsByTrip( + Long.valueOf(memberId), tripId, page, size, order); return ResponseEntity.status(HttpStatus.OK) .body( diff --git a/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java b/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java index ccb95f2..5163da7 100644 --- a/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java +++ b/src/main/java/com/ject/studytrip/trip/application/facade/TripReportFacade.java @@ -51,7 +51,7 @@ public TripRetrospectDetail getTripRetrospect(Long memberId, Long tripId, int pa Member member = memberQueryService.getValidMember(memberId); Trip trip = tripQueryService.getValidCompletedTrip(member.getId(), tripId); // 완료된 여행 Slice studyLogSlice = - studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size); + studyLogQueryService.getStudyLogsSliceByTripId(trip.getId(), page, size, "LATEST"); long studyLogCount = studyLogQueryService.getStudyLogCountByTripId(trip.getId()); long totalFocusHours = pomodoroQueryService.getTotalFocusHoursByTripId(trip.getId()); diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java index 6a942d8..d23acb9 100644 --- a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.java @@ -93,29 +93,57 @@ class getStudyLogsSliceByTripId { @Test @DisplayName("특정 여행의 학습 로그 목록을 페이징 처리와 최신순으로 정렬하고 반환한다") - void shouldReturnStudyLogsByTripIdWithSlice() { + void shouldReturnStudyLogsByTripIdWithLatestOrder() { // given Long tripId = courseTrip.getId(); List studyLogs = List.of(studyLog1, studyLog2); int page = 0; int size = 5; + String order = "LATEST"; Pageable pageable = PageRequest.of(page, size); Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); - given(studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc(tripId, pageable)) + given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)) .willReturn(mockSlice); // when Slice result = - studyLogQueryService.getStudyLogsSliceByTripId(tripId, page, size); + studyLogQueryService.getStudyLogsSliceByTripId(tripId, page, size, order); // then assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); assertThat(result.getContent().get(0)).isEqualTo(studyLog1); assertThat(result.getContent().get(1)).isEqualTo(studyLog2); } + + @Test + @DisplayName("특정 여행의 학습 로그 목록을 페이징 처리와 과거순으로 정렬하고 반환한다") + void shouldReturnStudyLogsByTripIdWithOldestOrder() { + // given + Long tripId = courseTrip.getId(); + List studyLogs = List.of(studyLog2, studyLog1); // 과거순이므로 순서 반대 + + int page = 0; + int size = 5; + String order = "OLDEST"; + Pageable pageable = PageRequest.of(page, size); + + Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); + + given(studyLogQueryRepository.findSliceByTripId(tripId, pageable, order)) + .willReturn(mockSlice); + + // when + Slice result = + studyLogQueryService.getStudyLogsSliceByTripId(tripId, page, size, order); + + // then + assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); + assertThat(result.getContent().get(0)).isEqualTo(studyLog2); + assertThat(result.getContent().get(1)).isEqualTo(studyLog1); + } } @Nested @@ -218,7 +246,7 @@ void shouldReturnCountWhenStudyLogExistsForTrip() { } @Nested - @DisplayName("getStudyLogsSliceByTripId 메서드는") + @DisplayName("getStudyLogsSliceByTripReportId 메서드는") class GetStudyLogsSliceByTripReportId { @Test @@ -235,13 +263,14 @@ void shouldReturnStudyLogsByTripReportIdWithSlice() { Slice mockSlice = new SliceImpl<>(studyLogs, pageable, false); given( - studyLogQueryRepository.findSliceByTripIdOrderByCreatedAtDesc( + studyLogQueryRepository.findSliceByTripReportIdOrderByCreatedAtDesc( tripReport.getId(), pageable)) .willReturn(mockSlice); // when Slice result = - studyLogQueryService.getStudyLogsSliceByTripId(tripReport.getId(), page, size); + studyLogQueryService.getStudyLogsSliceByTripReportId( + tripReport.getId(), page, size); // then assertThat(result.getContent().size()).isEqualTo(studyLogs.size()); diff --git a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java index 6a0dc95..ad0b935 100644 --- a/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.java @@ -567,13 +567,16 @@ void shouldReturnBadRequestWhenSelectedMissionIsAlreadyCompleted() throws Except class ListStudyLogs { private static final String DEFAULT_PAGE = "0"; private static final String DEFAULT_PAGE_SIZE = "5"; + private static final String DEFAULT_ORDER = "LATEST"; private ResultActions getResultActions( - String token, Object tripId, String page, String size) throws Exception { + String token, Object tripId, String page, String size, String order) + throws Exception { return mockMvc.perform( get("/api/trips/{tripId}/study-logs", tripId) .param("page", page) .param("size", size) + .param("order", order) .header(HttpHeaders.AUTHORIZATION, TokenFixture.TOKEN_PREFIX + token)); } @@ -588,7 +591,39 @@ void shouldLoadStudyLogsByTripWithSlicePaging() throws Exception { // when ResultActions resultActions = - getResultActions(token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions( + token, + courseTrip.getId(), + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + DEFAULT_ORDER); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.studyLogs").isNotEmpty()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.studyLogs[0].studyLogId").value(studyLog.getId())) + .andExpect(jsonPath("$.data.studyLogs[0].dailyMissions").isNotEmpty()) + .andExpect( + jsonPath("$.data.studyLogs[0].dailyMissions") + .value(Matchers.hasSize(studyLogDailyMissions.size()))); + } + + @Test + @DisplayName("order 파라미터를 OLDEST로 지정하면 과거순으로 정렬된 학습 로그 목록을 반환한다") + void shouldLoadStudyLogsByTripWithOldestOrder() throws Exception { + // given + StudyLog studyLog = studyLogTestHelper.saveStudyLog(member, dailyGoal); + List studyLogDailyMissions = + studyLogDailyMissionTestHelper.saveStudyLogDailyMissions( + studyLog, dailyMission); + + // when + ResultActions resultActions = + getResultActions( + token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, "OLDEST"); // then resultActions @@ -603,12 +638,33 @@ void shouldLoadStudyLogsByTripWithSlicePaging() throws Exception { .value(Matchers.hasSize(studyLogDailyMissions.size()))); } + @Test + @DisplayName("order 파라미터가 유효하지 않은 값이면 400 Bad Request를 반환한다") + void shouldReturnBadRequestWhenOrderIsInvalid() throws Exception { + // when + ResultActions resultActions = + getResultActions( + token, courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, "INVALID"); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())); + } + @Test @DisplayName("인증되지 않은 사용자일 경우 401 Unauthorized를 반환한다") void shouldReturnUnauthorizedWhenUnauthenticated() throws Exception { // when ResultActions resultActions = - getResultActions("", courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions( + "", courseTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); // then resultActions @@ -627,7 +683,7 @@ void shouldReturnBadRequestWhenTripIdTypeMismatch() throws Exception { // when ResultActions resultActions = - getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); // then resultActions @@ -649,7 +705,8 @@ void shouldReturnBadRequestWhenWhenPagingParameterTypeMismatch() throws Exceptio String size = "test"; // when - ResultActions resultActions = getResultActions(token, courseTrip, page, size); + ResultActions resultActions = + getResultActions(token, courseTrip, page, size, DEFAULT_ORDER); // then resultActions @@ -671,7 +728,8 @@ void shouldReturnBadRequestWhenWhenPagingParameterIsInvalid() throws Exception { String size = "100"; // when - ResultActions resultActions = getResultActions(token, courseTrip.getId(), page, size); + ResultActions resultActions = + getResultActions(token, courseTrip.getId(), page, size, DEFAULT_ORDER); // then resultActions @@ -693,7 +751,7 @@ void shouldReturnNotFoundWhenInvalidTripId() throws Exception { // when ResultActions resultActions = - getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions(token, tripId, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); // when & then resultActions @@ -713,7 +771,8 @@ void shouldReturnForbiddenWhenNotTripOwner() throws Exception { // when ResultActions resultActions = - getResultActions(token, newTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions( + token, newTrip.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); // then resultActions @@ -732,7 +791,8 @@ void shouldReturnBadRequestWhenAlreadyTrip() throws Exception { // when ResultActions resultActions = - getResultActions(token, deleted.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE); + getResultActions( + token, deleted.getId(), DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEFAULT_ORDER); // then resultActions