From 42ebb6ccd54286a9f21a5b75febb0d70c0a71496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A3=BC=EC=98=81?= <141599648+ljy1348@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:03:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EC=84=9C=EC=9E=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=B3=84=20=EB=82=A0=EC=A7=9C=EC=88=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=A0=95=EC=B1=85=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/UserNovelSortType.java | 28 +++++++++- .../WSSServer/library/domain/UserNovel.java | 5 ++ .../UserNovelCustomRepositoryImpl.java | 17 +++--- .../repository/cursor/UserNovelCursor.java | 7 ++- .../library/service/UserNovelService.java | 3 +- .../domain/common/UserNovelSortTypeTest.java | 37 +++++++++++++ .../library/domain/UserNovelTest.java | 42 +++++++++++++++ .../UserNovelCustomRepositoryImplTest.java | 54 +++++++++++++++++++ 8 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 src/test/java/org/websoso/WSSServer/domain/common/UserNovelSortTypeTest.java create mode 100644 src/test/java/org/websoso/WSSServer/library/domain/UserNovelTest.java create mode 100644 src/test/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImplTest.java diff --git a/src/main/java/org/websoso/WSSServer/domain/common/UserNovelSortType.java b/src/main/java/org/websoso/WSSServer/domain/common/UserNovelSortType.java index bd219333..1befdc0e 100644 --- a/src/main/java/org/websoso/WSSServer/domain/common/UserNovelSortType.java +++ b/src/main/java/org/websoso/WSSServer/domain/common/UserNovelSortType.java @@ -6,6 +6,11 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.DateExpression; +import com.querydsl.core.types.dsl.DateTimeExpression; +import com.querydsl.core.types.dsl.Expressions; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.websoso.WSSServer.exception.exception.CustomFilteringException; @@ -40,13 +45,18 @@ public static UserNovelSortType of(String sortType) { "given user novel sort type does not exist"); } + // 정렬 유형에 맞는 정렬 조건과 동일 값 정렬 기준을 반환한다. public List> orderSpecifiers() { return switch (this) { case CREATED_DESC -> List.of(userNovel.createdDate.desc(), userNovel.userNovelId.desc()); case CREATED_ASC -> List.of(userNovel.createdDate.asc(), userNovel.userNovelId.asc()); case TITLE, TITLE_ASC -> List.of(novel.title.asc(), userNovel.userNovelId.asc()); case TITLE_DESC -> List.of(novel.title.desc(), userNovel.userNovelId.desc()); - case READ_DATE -> List.of(userNovel.startDate.desc().nullsLast(), userNovel.userNovelId.desc()); + case READ_DATE -> List.of( + readDateExpression().desc().nullsLast(), + undatedCreatedDateExpression().desc().nullsLast(), + userNovel.userNovelId.desc() + ); case RATING_DESC -> List.of( new CaseBuilder() .when(userNovel.userNovelRating.eq(0.0f)) @@ -69,4 +79,20 @@ public List> orderSpecifiers() { ); }; } + + // 독서 상태에 따라 날짜순 정렬에 사용할 날짜 표현식을 생성한다. + public static DateExpression readDateExpression() { + return new CaseBuilder() + .when(userNovel.status.eq(ReadStatus.WATCHING)) + .then(userNovel.startDate) + .otherwise(userNovel.endDate); + } + + // 독서 날짜가 없는 항목에만 등록일 정렬 값을 부여한다. + private static DateTimeExpression undatedCreatedDateExpression() { + return new CaseBuilder() + .when(readDateExpression().isNull()) + .then(userNovel.createdDate) + .otherwise(Expressions.nullExpression(LocalDateTime.class)); + } } diff --git a/src/main/java/org/websoso/WSSServer/library/domain/UserNovel.java b/src/main/java/org/websoso/WSSServer/library/domain/UserNovel.java index b73bbc1f..13c89209 100644 --- a/src/main/java/org/websoso/WSSServer/library/domain/UserNovel.java +++ b/src/main/java/org/websoso/WSSServer/library/domain/UserNovel.java @@ -90,6 +90,11 @@ public static UserNovel create(ReadStatus status, Float userNovelRating, LocalDa return new UserNovel(status, userNovelRating, startDate, endDate, user, novel); } + // 상태별 날짜순 정렬에 사용할 독서 날짜를 반환한다. + public LocalDate getReadDate() { + return status == ReadStatus.WATCHING ? startDate : endDate; + } + /** * 관심 상태 지정 */ diff --git a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java index 51171b0e..104c6f53 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java @@ -3,6 +3,7 @@ import static org.websoso.WSSServer.domain.common.ReadStatus.QUIT; import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHED; import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHING; +import static org.websoso.WSSServer.domain.common.UserNovelSortType.readDateExpression; import static org.websoso.WSSServer.library.domain.QUserNovel.userNovel; import static org.websoso.WSSServer.novel.domain.QNovel.novel; import static org.websoso.WSSServer.novel.domain.QNovelGenre.novelGenre; @@ -11,6 +12,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; @@ -328,16 +330,19 @@ private BooleanExpression titleDescCursorCondition(UserNovelCursor cursor) { .and(userNovel.userNovelId.lt(cursor.lastUserNovelId()))); } + // 상태별 독서 날짜를 사용하고 날짜가 없으면 등록일 기준 다음 페이지 조건을 생성한다. private BooleanExpression readDateCursorCondition(UserNovelCursor cursor) { - if (cursor.lastStartDate() == null) { - return userNovel.startDate.isNull() - .and(userNovel.userNovelId.lt(cursor.lastUserNovelId())); + DateExpression readDate = readDateExpression(); + + if (cursor.lastReadDate() == null) { + return readDate.isNull() + .and(createdDescCursorCondition(cursor)); } - return userNovel.startDate.lt(cursor.lastStartDate()) - .or(userNovel.startDate.eq(cursor.lastStartDate()) + return readDate.lt(cursor.lastReadDate()) + .or(readDate.eq(cursor.lastReadDate()) .and(userNovel.userNovelId.lt(cursor.lastUserNovelId()))) - .or(userNovel.startDate.isNull()); + .or(readDate.isNull()); } private BooleanExpression ratingDescCursorCondition(UserNovelCursor cursor) { diff --git a/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java b/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java index 808a6535..7e7c843f 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java @@ -1,14 +1,17 @@ package org.websoso.WSSServer.library.repository.cursor; -import java.time.LocalDateTime; import java.time.LocalDate; +import java.time.LocalDateTime; +/** + * 사용자 서재 정렬별 다음 페이지 조회 기준값을 저장한다. + */ public record UserNovelCursor( Float lastRating, LocalDateTime lastCreatedDate, Long lastUserNovelId, Boolean rated, - LocalDate lastStartDate, + LocalDate lastReadDate, String lastTitle ) { } diff --git a/src/main/java/org/websoso/WSSServer/library/service/UserNovelService.java b/src/main/java/org/websoso/WSSServer/library/service/UserNovelService.java index 650c1f69..ced8a755 100644 --- a/src/main/java/org/websoso/WSSServer/library/service/UserNovelService.java +++ b/src/main/java/org/websoso/WSSServer/library/service/UserNovelService.java @@ -215,13 +215,14 @@ private List buildUserNovelAndNovelGetResponses(Li .toList(); } + // 조회 결과의 마지막 항목을 다음 페이지 조회용 커서로 변환한다. private UserNovelCursor toCursor(UserNovel userNovel) { return new UserNovelCursor( userNovel.getUserNovelRating(), userNovel.getCreatedDate(), userNovel.getUserNovelId(), Float.compare(userNovel.getUserNovelRating(), 0.0f) != 0, - userNovel.getStartDate(), + userNovel.getReadDate(), userNovel.getNovel().getTitle() ); } diff --git a/src/test/java/org/websoso/WSSServer/domain/common/UserNovelSortTypeTest.java b/src/test/java/org/websoso/WSSServer/domain/common/UserNovelSortTypeTest.java new file mode 100644 index 00000000..e75cc47b --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/domain/common/UserNovelSortTypeTest.java @@ -0,0 +1,37 @@ +package org.websoso.WSSServer.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.websoso.WSSServer.domain.common.UserNovelSortType.READ_DATE; +import static org.websoso.WSSServer.library.domain.QUserNovel.userNovel; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.OrderSpecifier.NullHandling; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 사용자 서재 정렬 유형의 QueryDSL 정렬 조건을 검증한다. + */ +class UserNovelSortTypeTest { + + // 날짜순 정렬이 상태별 독서 날짜와 날짜 없는 항목의 등록일을 사용하는지 검증한다. + @Test + @DisplayName("날짜순 정렬은 상태별 독서 날짜를 기준으로 최신순 정렬한다") + void orderByReadDate() { + List> orderSpecifiers = READ_DATE.orderSpecifiers(); + + assertThat(orderSpecifiers).hasSize(3); + assertThat(orderSpecifiers.get(0).getOrder()).isEqualTo(Order.DESC); + assertThat(orderSpecifiers.get(0).getNullHandling()).isEqualTo(NullHandling.NullsLast); + assertThat(orderSpecifiers.get(0).getTarget().toString()) + .contains("status", "WATCHING", "startDate", "endDate"); + assertThat(orderSpecifiers.get(1).getOrder()).isEqualTo(Order.DESC); + assertThat(orderSpecifiers.get(1).getNullHandling()).isEqualTo(NullHandling.NullsLast); + assertThat(orderSpecifiers.get(1).getTarget().toString()) + .contains("startDate", "endDate", "createdDate"); + assertThat(orderSpecifiers.get(2).getTarget()).isEqualTo(userNovel.userNovelId); + assertThat(orderSpecifiers.get(2).getOrder()).isEqualTo(Order.DESC); + } +} diff --git a/src/test/java/org/websoso/WSSServer/library/domain/UserNovelTest.java b/src/test/java/org/websoso/WSSServer/library/domain/UserNovelTest.java new file mode 100644 index 00000000..8aa51f6c --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/library/domain/UserNovelTest.java @@ -0,0 +1,42 @@ +package org.websoso.WSSServer.library.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.websoso.WSSServer.domain.common.ReadStatus.QUIT; +import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHED; +import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHING; + +import java.time.LocalDate; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.websoso.WSSServer.domain.common.ReadStatus; + +/** + * 사용자 서재 도메인의 상태별 독서 날짜 정책을 검증한다. + */ +class UserNovelTest { + + private static final LocalDate START_DATE = LocalDate.of(2026, 1, 1); + private static final LocalDate END_DATE = LocalDate.of(2026, 2, 1); + + // 읽는 중은 시작일, 완독과 하차는 마지막 날짜를 반환하는지 검증한다. + @ParameterizedTest + @MethodSource("readDateCases") + @DisplayName("독서 상태에 맞는 날짜순 정렬 날짜를 반환한다") + void getReadDateByStatus(ReadStatus status, LocalDate expected) { + UserNovel userNovel = UserNovel.create(status, 0.0f, START_DATE, END_DATE, null, null); + + assertThat(userNovel.getReadDate()).isEqualTo(expected); + } + + // 상태별 기대 독서 날짜 테스트 데이터를 제공한다. + private static Stream readDateCases() { + return Stream.of( + Arguments.of(WATCHING, START_DATE), + Arguments.of(WATCHED, END_DATE), + Arguments.of(QUIT, END_DATE) + ); + } +} diff --git a/src/test/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImplTest.java b/src/test/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImplTest.java new file mode 100644 index 00000000..436262a1 --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImplTest.java @@ -0,0 +1,54 @@ +package org.websoso.WSSServer.library.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.querydsl.core.types.dsl.BooleanExpression; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.websoso.WSSServer.library.repository.cursor.UserNovelCursor; + +/** + * 사용자 서재의 날짜순 커서 조건을 검증한다. + */ +class UserNovelCustomRepositoryImplTest { + + private static final LocalDateTime CREATED_DATE = LocalDateTime.of(2026, 1, 1, 0, 0); + private final UserNovelCustomRepositoryImpl repository = new UserNovelCustomRepositoryImpl(null); + + // 날짜가 없는 커서 이후 항목을 등록일과 식별자 내림차순으로 조회하는지 검증한다. + @Test + @DisplayName("날짜가 없는 항목은 등록 최신순 커서를 적용한다") + void createReadDateCursorConditionWithoutReadDate() { + UserNovelCursor cursor = createCursor(null); + + BooleanExpression condition = ReflectionTestUtils.invokeMethod( + repository, "readDateCursorCondition", cursor); + + assertThat(condition).isNotNull(); + assertThat(condition.toString()) + .contains("startDate", "endDate", "createdDate", "userNovelId"); + } + + // 날짜가 있는 커서 이후 항목을 독서 날짜와 식별자 내림차순으로 조회하는지 검증한다. + @Test + @DisplayName("날짜가 있는 항목은 상태별 독서 날짜 커서를 적용한다") + void createReadDateCursorConditionWithReadDate() { + UserNovelCursor cursor = createCursor(LocalDate.of(2026, 1, 1)); + + BooleanExpression condition = ReflectionTestUtils.invokeMethod( + repository, "readDateCursorCondition", cursor); + + assertThat(condition).isNotNull(); + assertThat(condition.toString()) + .contains("status", "WATCHING", "startDate", "endDate", "userNovelId") + .doesNotContain("createdDate"); + } + + // 날짜순 커서 조건 테스트에 필요한 커서를 생성한다. + private UserNovelCursor createCursor(LocalDate readDate) { + return new UserNovelCursor(0.0f, CREATED_DATE, 1L, false, readDate, "제목"); + } +} From a7eb9e2f2e51baa209f62582158486c2bdd38629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A3=BC=EC=98=81?= <141599648+ljy1348@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:21:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[REFACTOR]=20=EC=84=9C=EC=9E=AC=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EB=8C=80=ED=91=9C=20=EB=82=A0=EC=A7=9C=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/UserNovelCustomRepositoryImpl.java | 14 +++++++------- .../library/repository/cursor/UserNovelCursor.java | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java index 104c6f53..d2e1afa5 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java @@ -330,19 +330,19 @@ private BooleanExpression titleDescCursorCondition(UserNovelCursor cursor) { .and(userNovel.userNovelId.lt(cursor.lastUserNovelId()))); } - // 상태별 독서 날짜를 사용하고 날짜가 없으면 등록일 기준 다음 페이지 조건을 생성한다. + // 상태별 대표 날짜를 사용하고 날짜가 없으면 등록일 기준 다음 페이지 조건을 생성한다. private BooleanExpression readDateCursorCondition(UserNovelCursor cursor) { - DateExpression readDate = readDateExpression(); + DateExpression representativeDate = readDateExpression(); - if (cursor.lastReadDate() == null) { - return readDate.isNull() + if (cursor.lastRepresentativeDate() == null) { + return representativeDate.isNull() .and(createdDescCursorCondition(cursor)); } - return readDate.lt(cursor.lastReadDate()) - .or(readDate.eq(cursor.lastReadDate()) + return representativeDate.lt(cursor.lastRepresentativeDate()) + .or(representativeDate.eq(cursor.lastRepresentativeDate()) .and(userNovel.userNovelId.lt(cursor.lastUserNovelId()))) - .or(readDate.isNull()); + .or(representativeDate.isNull()); } private BooleanExpression ratingDescCursorCondition(UserNovelCursor cursor) { diff --git a/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java b/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java index 7e7c843f..1ff41801 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/cursor/UserNovelCursor.java @@ -11,7 +11,7 @@ public record UserNovelCursor( LocalDateTime lastCreatedDate, Long lastUserNovelId, Boolean rated, - LocalDate lastReadDate, + LocalDate lastRepresentativeDate, String lastTitle ) { }