From a3bc1849df8f5417e130eb6eab1d844729b4663a Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:06:48 +0900 Subject: [PATCH 01/14] =?UTF-8?q?[BUILD]=20=EC=BA=90=EC=8B=9C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index e43fe0b..efe39e7 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,10 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-cache' + + // cache + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // lombok compileOnly 'org.projectlombok:lombok' From 329f8ac44828ce969620bc0c1a5632eb732de94f Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:07:21 +0900 Subject: [PATCH 02/14] =?UTF-8?q?[FEAT]=20=EC=84=9C=EC=9E=AC,=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/app/nook/focus/domain/Focus.java | 4 +-- .../java/app/nook/library/domain/Library.java | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/main/java/app/nook/focus/domain/Focus.java b/src/main/java/app/nook/focus/domain/Focus.java index fed3c69..e365c6a 100644 --- a/src/main/java/app/nook/focus/domain/Focus.java +++ b/src/main/java/app/nook/focus/domain/Focus.java @@ -15,8 +15,8 @@ name = "focuses", indexes = { @Index( - name = "idx_focus_library", - columnList = "library_id" + name = "idx_focus_library_started", + columnList = "library_id, started_at" ) } ) diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index 0bcb440..f669767 100644 --- a/src/main/java/app/nook/library/domain/Library.java +++ b/src/main/java/app/nook/library/domain/Library.java @@ -27,20 +27,16 @@ }, indexes = { @Index( - name = "idx_library_user", - columnList = "user_id" + name = "idx_library_user_status_id", + columnList = "user_id, reading_status, library_id DESC" ), - @Index( - name = "idx_library_book", - columnList = "book_id" - ) } ) public class Library extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "bookcase_id") + @Column(name = "library_id") private Long id; @Enumerated(EnumType.STRING) @@ -71,10 +67,23 @@ public Library( ){ this.user = user; this.book = book; + this.startedAt = LocalDateTime.now().toLocalDate(); + } + + + // 포커스 시 읽은 기록 업데이트 + public void recordFocus(long addedMinutes) { + this.focusMin += addedMinutes; } - // 포커스를 시작했는데 독서 전이면 독서 중으로 변경 + // 완독 -> 독서 중 + // 독서 중 -> 완독 : endTime 업데이트 public void updateStatus(ReadingStatus readingStatus) { this.readingStatus = readingStatus; + if(readingStatus.equals(ReadingStatus.FINISHED)){ + this.endedAt = LocalDateTime.now().toLocalDate(); + } } + + } From fe47ede3b3cbf3570a741cea14c5ab4e126a4361 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:07:46 +0900 Subject: [PATCH 03/14] =?UTF-8?q?[FEAT]=20=EC=BA=90=EC=8B=9C=20=EB=A7=A4?= =?UTF-8?q?=EB=8B=88=EC=A0=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/nook/global/config/CacheConfig.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/app/nook/global/config/CacheConfig.java diff --git a/src/main/java/app/nook/global/config/CacheConfig.java b/src/main/java/app/nook/global/config/CacheConfig.java new file mode 100644 index 0000000..61e874b --- /dev/null +++ b/src/main/java/app/nook/global/config/CacheConfig.java @@ -0,0 +1,44 @@ +package app.nook.global.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + + RedisCacheConfiguration defaultConfig = + RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)); + + Map cacheConfigs = new HashMap<>(); + + cacheConfigs.put("libraryMonthlyCurrent", + defaultConfig.entryTtl(Duration.ofMinutes(3))); + + cacheConfigs.put("libraryMonthlyPast", + defaultConfig.entryTtl(Duration.ofHours(24))); + + cacheConfigs.put("focusMonthlyCurrent", + defaultConfig.entryTtl(Duration.ofMinutes(3))); + + cacheConfigs.put("libraryStatusFirstPage", + defaultConfig.entryTtl(Duration.ofMinutes(2))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} From 4b86d1291123ccc2b7d713d12750e6ab6dc66f64 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:09:29 +0900 Subject: [PATCH 04/14] =?UTF-8?q?[FEAT]=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/app/nook/NookApplication.java | 2 + .../focus/repository/FocusRepository.java | 83 +++++++++++ .../controller/LibraryStatsController.java | 55 +++++++ .../app/nook/library/dto/FocusRankDto.java | 64 +++++++++ .../app/nook/library/dto/LibraryViewDto.java | 12 -- .../library/repository/LibraryRepository.java | 6 +- .../repository/LibraryRepositoryCustom.java | 11 -- .../repository/LibraryRepositoryImpl.java | 40 ------ .../library/service/LibraryFocusUtil.java | 25 ++++ .../nook/library/service/LibraryService.java | 78 ++++++++-- .../library/service/LibraryStatsService.java | 135 ++++++++++++++++++ .../timeline/converter/TimeLineConverter.java | 7 +- .../nook/timeline/domain/BookTimeLine.java | 8 +- src/main/resources/application.yml | 5 +- 14 files changed, 440 insertions(+), 91 deletions(-) create mode 100644 src/main/java/app/nook/focus/repository/FocusRepository.java create mode 100644 src/main/java/app/nook/library/controller/LibraryStatsController.java create mode 100644 src/main/java/app/nook/library/dto/FocusRankDto.java delete mode 100644 src/main/java/app/nook/library/repository/LibraryRepositoryCustom.java delete mode 100644 src/main/java/app/nook/library/repository/LibraryRepositoryImpl.java create mode 100644 src/main/java/app/nook/library/service/LibraryFocusUtil.java create mode 100644 src/main/java/app/nook/library/service/LibraryStatsService.java diff --git a/src/main/java/app/nook/NookApplication.java b/src/main/java/app/nook/NookApplication.java index a62668d..0bb3161 100644 --- a/src/main/java/app/nook/NookApplication.java +++ b/src/main/java/app/nook/NookApplication.java @@ -2,11 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing +@EnableCaching public class NookApplication { public static void main(String[] args) { diff --git a/src/main/java/app/nook/focus/repository/FocusRepository.java b/src/main/java/app/nook/focus/repository/FocusRepository.java new file mode 100644 index 0000000..f432bdc --- /dev/null +++ b/src/main/java/app/nook/focus/repository/FocusRepository.java @@ -0,0 +1,83 @@ +package app.nook.focus.repository; + +import app.nook.focus.domain.Focus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface FocusRepository extends JpaRepository { + + interface FocusYearMonthProjection { + Integer getYearValue(); + Integer getMonthValue(); + } + + interface MonthlyFocusStatsProjection { + LocalDate getDateValue(); + Long getBookId(); + String getCoverImageUrl(); + Long getTotalSec(); + } + + interface FocusTimeStatsProjection { + LocalDate getDateValue(); + Long getTotalSec(); + } + + @Query(""" + select + FUNCTION('DATE', f.startedAt) as dateValue, + f.library.book.id as bookId, + f.library.book.coverImageUrl as coverImageUrl, + sum(f.durationSec) as totalSec + from Focus f + join f.library l + where l.user.id = :userId + and f.startedAt >= :start + and f.startedAt < :end + group by FUNCTION('DATE', f.startedAt), f.library.book.id, f.library.book.coverImageUrl + order by sum(f.durationSec) desc + """) + List findMonthlyFocusStats( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + + @Query(""" + select distinct + YEAR(f.startedAt) as yearValue, + MONTH(f.startedAt) as monthValue + from Focus f + where f.library.id = :libraryId + and f.library.user.id = :userId + """) + List findDistinctFocusYearMonthsByLibraryAndUser( + @Param("libraryId") Long libraryId, + @Param("userId") Long userId + ); + + + // 포커스 통계 조회 + @Query(""" + select + FUNCTION('DATE', f.startedAt) as dateValue, + sum(f.durationSec) as totalSec + from Focus f + join f.library l + where l.user.id = :userId + and f.startedAt >= :start + and f.startedAt < :end + group by FUNCTION('DATE', f.startedAt) + """) + List findFocusTimeStats( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + +} diff --git a/src/main/java/app/nook/library/controller/LibraryStatsController.java b/src/main/java/app/nook/library/controller/LibraryStatsController.java new file mode 100644 index 0000000..eee1839 --- /dev/null +++ b/src/main/java/app/nook/library/controller/LibraryStatsController.java @@ -0,0 +1,55 @@ +package app.nook.library.controller; + +import app.nook.global.response.ApiResponse; +import app.nook.global.response.SuccessCode; +import app.nook.library.dto.FocusRankDto; +import app.nook.library.service.LibraryStatsService; +import app.nook.user.service.CustomUserDetails; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.YearMonth; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/library/stats") +@Validated +public class LibraryStatsController { + + private final LibraryStatsService libraryStatsService; + + // 서재 월별 포커스 통계 조회 + @GetMapping("/monthly") + public ApiResponse viewMonthly( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam + @NotNull + @DateTimeFormat(pattern = "yyyy-MM") + YearMonth yearMonth + ) { + FocusRankDto.MonthlyBooksResponseDto response = + libraryStatsService.viewMonthly(userDetails.getUser().getId(), yearMonth); + return ApiResponse.onSuccess(response, SuccessCode.OK); + } + + // 서재 월별 포커스 시간 통계 조회 + @GetMapping("/focus-monthly") + public ApiResponse viewFocusTimeStats( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam + @NotNull + @DateTimeFormat(pattern = "yyyy-MM") + YearMonth yearMonth + ) { + FocusRankDto.FocusBookResponseDto response = + libraryStatsService.viewFocusTimeStats(userDetails.getUser().getId(), yearMonth); + return ApiResponse.onSuccess(response, SuccessCode.OK); + } +} diff --git a/src/main/java/app/nook/library/dto/FocusRankDto.java b/src/main/java/app/nook/library/dto/FocusRankDto.java new file mode 100644 index 0000000..db69597 --- /dev/null +++ b/src/main/java/app/nook/library/dto/FocusRankDto.java @@ -0,0 +1,64 @@ +package app.nook.library.dto; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +public class FocusRankDto { + + // 포커스 전체 통계 + public record FocusBookResponseDto( + YearMonth yearMonth, + int totalFocusMin, + List focusBookItems + ){} + + // 포커스 랭킹 통계 + public record FocusDateItem( + LocalDate date, + FocusTimeSlot timeSlot + ){} + + // 월 통계 + public record MonthlyBooksResponseDto( + YearMonth yearMonth, + int totalFocusMin, + List days + ){} + + // 통계 시 책 아이템 + public record DailyBookItem( + LocalDate date, + Long bookCount, + BookCalendarInfo topBook + ){} + + public record BookCalendarInfo( + Long bookId, + String CoverUrl + ){} + + public record BookDetailInfo( + Long bookId, + String coverUrl, + String title, + String author, + Long focusMin + ) {} + + // 중간 row dto + public record MonthlyFocusRow( + LocalDate date, + Long bookId, + String coverImageUrl, + Long totalSec + ){} + + // 중간 focus row dto + public record FocusTimeRow( + LocalDate date, + Long totalSec + ){} + + +} diff --git a/src/main/java/app/nook/library/dto/LibraryViewDto.java b/src/main/java/app/nook/library/dto/LibraryViewDto.java index 906d516..c558d51 100644 --- a/src/main/java/app/nook/library/dto/LibraryViewDto.java +++ b/src/main/java/app/nook/library/dto/LibraryViewDto.java @@ -39,18 +39,6 @@ public record UserBookResponseDto( int focusMin, String coverUrl ){} - - public record FocusBookResponseDto( - YearMonth yearMonth, - int totalFocusMin, - List focusBookItems - ){} - - public record FocusBookItem( - LocalDate date, - FocusTimeSlot timeSlot - ){} - public record StatusBookResponseDto( ReadingStatus readingStatus, int totalBookNum, diff --git a/src/main/java/app/nook/library/repository/LibraryRepository.java b/src/main/java/app/nook/library/repository/LibraryRepository.java index beaeced..fc2e6da 100644 --- a/src/main/java/app/nook/library/repository/LibraryRepository.java +++ b/src/main/java/app/nook/library/repository/LibraryRepository.java @@ -3,6 +3,7 @@ import app.nook.book.domain.Book; import app.nook.library.domain.Library; import app.nook.library.domain.enums.ReadingStatus; +import app.nook.library.dto.FocusRankDto; import app.nook.user.domain.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -10,12 +11,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.security.core.parameters.P; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; import java.util.Optional; import java.util.Set; -public interface LibraryRepository extends JpaRepository, LibraryRepositoryCustom{ +public interface LibraryRepository extends JpaRepository { Library findByUserAndBook(User user, Book book); diff --git a/src/main/java/app/nook/library/repository/LibraryRepositoryCustom.java b/src/main/java/app/nook/library/repository/LibraryRepositoryCustom.java deleted file mode 100644 index 8a5e2d5..0000000 --- a/src/main/java/app/nook/library/repository/LibraryRepositoryCustom.java +++ /dev/null @@ -1,11 +0,0 @@ -package app.nook.library.repository; - -import app.nook.library.domain.Library; -import app.nook.user.domain.User; - -import java.time.YearMonth; -import java.util.List; - -public interface LibraryRepositoryCustom { - List findByUserAndYearMonth(User user, YearMonth yearMonth); -} diff --git a/src/main/java/app/nook/library/repository/LibraryRepositoryImpl.java b/src/main/java/app/nook/library/repository/LibraryRepositoryImpl.java deleted file mode 100644 index 23dfc95..0000000 --- a/src/main/java/app/nook/library/repository/LibraryRepositoryImpl.java +++ /dev/null @@ -1,40 +0,0 @@ -package app.nook.library.repository; - - -import app.nook.library.domain.Library; -import app.nook.library.domain.QLibrary; -import app.nook.user.domain.User; -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; - -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.List; - -@RequiredArgsConstructor -public class LibraryRepositoryImpl implements LibraryRepositoryCustom{ - - private final JPAQueryFactory queryFactory; - - // TODO : focus시간 기반으로 수정 - @Override - public List findByUserAndYearMonth(User user, YearMonth yearMonth) { - LocalDate startDate = yearMonth.atDay(1); - LocalDate endDate = yearMonth.atEndOfMonth(); - - QLibrary library = QLibrary.library; - - return queryFactory - .selectFrom(library) - .where( - library.user.eq(user), - library.createdDate.between( - startDate.atStartOfDay(), - endDate.atTime(23, 59, 59) - ) - ) - .orderBy(library.createdDate.desc()) - .fetch(); - } - -} diff --git a/src/main/java/app/nook/library/service/LibraryFocusUtil.java b/src/main/java/app/nook/library/service/LibraryFocusUtil.java new file mode 100644 index 0000000..642b2b6 --- /dev/null +++ b/src/main/java/app/nook/library/service/LibraryFocusUtil.java @@ -0,0 +1,25 @@ +package app.nook.library.service; + +import app.nook.library.dto.FocusTimeSlot; + +public class LibraryFocusUtil { + + // 하루에 포커스 시간 계산, 한달 중 가장 많이 포커스한 날짜 기준으로 나머지를 계산 + public static FocusTimeSlot toFocusTimeSlot(long daySec, long maxDaySec) { + if (daySec <= 0 || maxDaySec <= 0) { + return FocusTimeSlot.FOCUS_00; + } + + double ratio = (double) daySec / (double) maxDaySec; + if (ratio <= 0.25d) { + return FocusTimeSlot.FOCUS_01; + } + if (ratio <= 0.50d) { + return FocusTimeSlot.FOCUS_02; + } + if (ratio <= 0.75d) { + return FocusTimeSlot.FOCUS_03; + } + return FocusTimeSlot.FOCUS_04; + } +} diff --git a/src/main/java/app/nook/library/service/LibraryService.java b/src/main/java/app/nook/library/service/LibraryService.java index 61664b1..e6997ca 100644 --- a/src/main/java/app/nook/library/service/LibraryService.java +++ b/src/main/java/app/nook/library/service/LibraryService.java @@ -4,6 +4,7 @@ import app.nook.book.dto.BookResponseDto; import app.nook.book.exception.BookErrorCode; import app.nook.book.repository.BookRepository; +import app.nook.focus.repository.FocusRepository; import app.nook.global.dto.CursorResponse; import app.nook.global.exception.CustomException; import app.nook.global.response.ErrorCode; @@ -20,6 +21,9 @@ import app.nook.timeline.repository.BookTimeLineRepository; import app.nook.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -39,8 +43,11 @@ public class LibraryService { private final LibraryRepository libraryRepository; private final BookRepository bookRepository; private final BookTimeLineRepository bookTimeLineRepository; + private final FocusRepository focusRepository; + private final CacheManager cacheManager; - // TODO: Book 도메인 에러코드로 추후 수정 + + // 서재 책 개수 조회 // 서재 책 등록 @Transactional @@ -52,10 +59,7 @@ public void save(User user,Long bookId) { throw new CustomException(LibraryErrorCode.BOOK_ALREADY_EXIST); // 서재 생성 - Library library = Library.builder() - .user(user) - .book(book) - .build(); + Library library = new Library(user,book); Library savedLibrary = libraryRepository.save(library); // 타임라인 업데이트 @@ -66,18 +70,32 @@ public void save(User user,Long bookId) { savedLibrary.getId() ); bookTimeLineRepository.save(timeLine); + evictStatusBookFirstPageCaches(user.getId()); } // 서재 책 삭제 @Transactional public void deleteById(User user, Long bookId){ + // 책 존재 검증 Book book = bookRepository.findById(bookId) .orElseThrow(() -> new CustomException(BookErrorCode.BOOK_NOT_FOUND)); Library library = libraryRepository.findByUserAndBook(user,book); - // 책 존재 확인 + // 서재 책 존재 확인 if (library == null) throw new CustomException(LibraryErrorCode.BOOK_NOT_EXIST); + + // 캐시 무효화에 필요한 데이터 조회 + List affectedYearMonths = + focusRepository.findDistinctFocusYearMonthsByLibraryAndUser( + library.getId(), + user.getId() + ); + libraryRepository.delete(library); + + // 캐시 무효화 + evictMonthlyStatsCaches(user.getId(), affectedYearMonths); + evictStatusBookFirstPageCaches(user.getId()); } // 서재 책 상태변경 @@ -101,16 +119,15 @@ public void changeStatus(User user, ReadingStatusRequestDto requestDto) { library.getReadingStatus().toString(), library.getId()); bookTimeLineRepository.save(timeLine); + evictStatusBookFirstPageCaches(user.getId()); } - // 서재 월별 책 조회 -// public LibraryViewDto.MonthlyBookResponseDto viewMonthly(User user, YearMonth yearMonth){ -// libraryRepository.findByUserAndYearMonth(user,yearMonth) -// } - - // 서재 포커스 시간별 책 조회 - // 서재 상태별 책 조회 + @Cacheable( + value = "libraryStatusFirstPage", + key = "#user.id + ':' + #status", + condition = "#cursor == null && #size == 20" + ) public LibraryViewDto.StatusBookResponseDto viewBooksByStatus( User user, ReadingStatus status, @@ -155,4 +172,39 @@ public Page searchBooksInLibrary(Long userId, String keyword, int page, Pageable pageable = PageRequest.of(page, size); return libraryRepository.searchByUserIdAndKeyword(userId, escapedKeyword, pageable); } + + // 캐시 무효화 월별 + private void evictMonthlyStatsCaches( + Long userId, + List yearMonths + ) { + if (yearMonths == null || yearMonths.isEmpty()) { + return; + } + Cache monthlyCache = cacheManager.getCache("libraryMonthlyCurrent"); + Cache focusTimeCache = cacheManager.getCache("focusMonthlyCurrent"); + + for (FocusRepository.FocusYearMonthProjection yearMonth : yearMonths) { + int year = yearMonth.getYearValue(); + int month = yearMonth.getMonthValue(); + String key = userId + ":" + YearMonth.of(year, month); + if (monthlyCache != null) { + monthlyCache.evict(key); + } + if (focusTimeCache != null) { + focusTimeCache.evict(key); + } + } + } + + // 기본 상태별 목록 캐시 무효화 - 첫 페이지 + private void evictStatusBookFirstPageCaches(Long userId) { + Cache cache = cacheManager.getCache("libraryStatusFirstPage"); + if (cache == null) { + return; + } + for (ReadingStatus status : ReadingStatus.values()) { + cache.evict(userId + ":" + status); + } + } } diff --git a/src/main/java/app/nook/library/service/LibraryStatsService.java b/src/main/java/app/nook/library/service/LibraryStatsService.java new file mode 100644 index 0000000..4622eee --- /dev/null +++ b/src/main/java/app/nook/library/service/LibraryStatsService.java @@ -0,0 +1,135 @@ +package app.nook.library.service; + +import app.nook.focus.repository.FocusRepository; +import app.nook.library.dto.FocusRankDto; +import app.nook.library.dto.FocusTimeSlot; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LibraryStatsService { + + private final FocusRepository focusRepository; + + // 서재 월별 책 조회 + @Cacheable( + value = "libraryMonthlyCurrent", + key = "#userId + ':' + #yearMonth" + ) + public FocusRankDto.MonthlyBooksResponseDto viewMonthly(Long userId, YearMonth yearMonth) { + // 월 범위 계산 + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1) + .atDay(1) + .atStartOfDay(); + + // 집계 결과 받아오기 + List rows = focusRepository.findMonthlyFocusStats(userId, start, end) + .stream() + .map(row -> new FocusRankDto.MonthlyFocusRow( + row.getDateValue(), + row.getBookId(), + row.getCoverImageUrl(), + row.getTotalSec() + )) + .toList(); + + // 월 전체 초를 분으로 변환 + long totalSec = rows.stream() + .mapToLong(FocusRankDto.MonthlyFocusRow::totalSec) + .sum(); + int totalFocusMin = (int) (totalSec / 60); + + // 날짜별로 다시 그룹화 + Map> groupedByDate = rows.stream() + .collect( + Collectors.groupingBy(FocusRankDto.MonthlyFocusRow::date) + ); + + // DailyBookItem으로 매핑 + List dailyBookItems = groupedByDate.entrySet().stream() + .map(entry -> { + LocalDate date = entry.getKey(); + List dayRows = entry.getValue(); + + // 날짜별 다른 책 개수 + long bookCount = dayRows.size(); + + // 가장 오래 읽은 책 + FocusRankDto.MonthlyFocusRow top = dayRows.stream() + .max(Comparator.comparingLong(FocusRankDto.MonthlyFocusRow::totalSec)) + .orElse(null); + + FocusRankDto.BookCalendarInfo topBook = null; + + // 오래 읽은 책이 있으면 매핑 + if (top!=null) { + topBook = new FocusRankDto.BookCalendarInfo( + top.bookId(), + top.coverImageUrl() + ); + } + return new FocusRankDto.DailyBookItem(date, bookCount, topBook); + }) + .sorted(Comparator.comparing(FocusRankDto.DailyBookItem::date)) // 오름차순 정렬 + .toList(); + return new FocusRankDto.MonthlyBooksResponseDto(yearMonth,totalFocusMin,dailyBookItems); + } + + // 서재 포커스 시간별 책 조회 + @Cacheable( + value = "focusMonthlyCurrent", + key = "#userId + ':' + #yearMonth" + ) + public FocusRankDto.FocusBookResponseDto viewFocusTimeStats(Long userId, YearMonth yearMonth) { + // 월 범위 계산 + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1) + .atDay(1) + .atStartOfDay(); + + // 집계 결과 받아오기 + List rows = focusRepository.findFocusTimeStats(userId, start, end) + .stream() + .map(row -> new FocusRankDto.FocusTimeRow( + row.getDateValue(), + row.getTotalSec() + )) + .toList(); + + // 월 전체 초를 분으로 변환 + long totalSec = rows.stream() + .mapToLong(FocusRankDto.FocusTimeRow::totalSec) + .sum(); + int totalFocusMin = (int) (totalSec / 60); + + // 가장 많이 포커스한 날짜 초 단위 + long maxDaySec = rows.stream() + .mapToLong(FocusRankDto.FocusTimeRow::totalSec) + .max() + .orElse(0L); + + // 날짜별로 FocusDateItem 매핑 + List focusBookItems = rows.stream() + .sorted(Comparator.comparing(FocusRankDto.FocusTimeRow::date)) + .map(row -> new FocusRankDto.FocusDateItem( + row.date(), + LibraryFocusUtil.toFocusTimeSlot(row.totalSec(), maxDaySec) + )) + .toList(); + + return new FocusRankDto.FocusBookResponseDto(yearMonth, totalFocusMin, focusBookItems); + } +} diff --git a/src/main/java/app/nook/timeline/converter/TimeLineConverter.java b/src/main/java/app/nook/timeline/converter/TimeLineConverter.java index 69b69a4..7a6f212 100644 --- a/src/main/java/app/nook/timeline/converter/TimeLineConverter.java +++ b/src/main/java/app/nook/timeline/converter/TimeLineConverter.java @@ -13,11 +13,6 @@ public static BookTimeLine toBookTimeLine( String snapshotValue, Long targetId ) { - return BookTimeLine.builder() - .library(library) - .type(bookTimeLineType) - .snapshotValue(snapshotValue) - .targetId(targetId) - .build(); + return new BookTimeLine(library,bookTimeLineType,snapshotValue,targetId); } } diff --git a/src/main/java/app/nook/timeline/domain/BookTimeLine.java b/src/main/java/app/nook/timeline/domain/BookTimeLine.java index f764ebb..dc22d5c 100644 --- a/src/main/java/app/nook/timeline/domain/BookTimeLine.java +++ b/src/main/java/app/nook/timeline/domain/BookTimeLine.java @@ -17,13 +17,9 @@ name = "book_timelines", indexes = { @Index( - name = "idx_book_timelines_library", - columnList = "library_id" + name = "idx_book_timelines_library_created", + columnList = "library_id, created_at DESC" ), - @Index( - name = "idx_book_timelines_type", - columnList = "type" - ) } ) public class BookTimeLine extends BaseEntity { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c30eaa7..5e0d389 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,7 +5,6 @@ spring: name: nook-api profiles: active: ${SPRING_PROFILES_ACTIVE:local} - datasource: url: ${DB_URL} username: ${DB_USERNAME} @@ -20,8 +19,10 @@ spring: format_sql: true show_sql: false dialect: org.hibernate.dialect.PostgreSQLDialect + generate_statistics: true open-in-view: false - + cache: + type: redis data: redis: host: ${REDIS_HOST:localhost} From c3c13a52f2de66e21d35ffc2246342a1b262c653 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:10:38 +0900 Subject: [PATCH 05/14] =?UTF-8?q?[TEST]=20=EC=84=9C=EC=9E=AC=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84/=EC=BA=90=EC=8B=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/library.adoc | 16 ++ .../library/LibraryStatsControllerTest.java | 147 ++++++++++++ .../nook/focus/service/ThemeServiceTest.java | 8 +- .../LibraryCachingIntegrationTest.java | 226 ++++++++++++++++++ .../library/service/LibraryServiceTest.java | 8 + .../service/LibraryStatsServiceTest.java | 160 +++++++++++++ 6 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java create mode 100644 src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java create mode 100644 src/test/java/app/nook/library/service/LibraryStatsServiceTest.java diff --git a/src/docs/asciidoc/library.adoc b/src/docs/asciidoc/library.adoc index f243ab7..2bc1e3d 100644 --- a/src/docs/asciidoc/library.adoc +++ b/src/docs/asciidoc/library.adoc @@ -30,3 +30,19 @@ include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/h include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/request-headers.adoc[] include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/http-response.adoc[] include::{snippets}/library-controller-test/서재_상태별_책_조회_성공/response-fields.adoc[] + +=== 서재 월별 포커스 통계 조회 + +include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/http-request.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/request-headers.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/query-parameters.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/http-response.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_통계_조회_성공/response-fields.adoc[] + +=== 서재 월별 포커스 시간 통계 조회 + +include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/http-request.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/request-headers.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/query-parameters.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/http-response.adoc[] +include::{snippets}/library-stats-controller-test/월별_포커스_시간_통계_조회_성공/response-fields.adoc[] diff --git a/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java new file mode 100644 index 0000000..9df3e98 --- /dev/null +++ b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java @@ -0,0 +1,147 @@ +package app.nook.controller.library; + +import app.nook.focus.repository.FocusRepository; +import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.docs.ApiResponseSnippet; +import app.nook.library.dto.FocusRankDto; +import app.nook.library.dto.FocusTimeSlot; +import app.nook.library.service.LibraryStatsService; +import app.nook.user.domain.User; +import app.nook.user.domain.enums.UserRole; +import app.nook.user.service.CustomUserDetails; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LibraryStatsControllerTest extends AbstractRestDocsTests { + + @MockitoBean + private LibraryStatsService libraryStatsService; + + @MockitoBean + private FocusRepository focusRepository; + + @Test + void 월별_포커스_통계_조회_성공() throws Exception { + // given + User user = User.builder() + .email("jiwon@kakao.com") + .nickName("jiwon") + .provider("kakao") + .providerId("provider-id") + .role(UserRole.USER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(user); + + FocusRankDto.MonthlyBooksResponseDto response = + new FocusRankDto.MonthlyBooksResponseDto( + YearMonth.of(2026, 2), + 60, + List.of( + new FocusRankDto.DailyBookItem( + LocalDate.of(2026, 2, 1), + 2L, + new FocusRankDto.BookCalendarInfo(101L, "https://example.com/101.jpg") + ) + ) + ); + + given(libraryStatsService.viewMonthly(any(), any(YearMonth.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/library/stats/monthly") + .param("yearMonth", "2026-02") + .header("Authorization", "Bearer test-access-token") + .with(user(userDetails)) + ) + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + queryParameters( + parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), + fieldWithPath("result.totalFocusMin").type(JsonFieldType.NUMBER).description("월 전체 포커스 시간(분)"), + fieldWithPath("result.days").type(JsonFieldType.ARRAY).description("일자별 통계 목록"), + fieldWithPath("result.days[].date").type(JsonFieldType.STRING).description("날짜"), + fieldWithPath("result.days[].bookCount").type(JsonFieldType.NUMBER).description("해당 날짜에 읽은 책 수"), + fieldWithPath("result.days[].topBook").type(JsonFieldType.OBJECT).optional().description("가장 오래 읽은 책"), + fieldWithPath("result.days[].topBook.bookId").type(JsonFieldType.NUMBER).optional().description("도서 ID"), + fieldWithPath("result.days[].topBook.CoverUrl").type(JsonFieldType.STRING).optional().description("도서 커버 URL") + )) + )); + } + + @Test + void 월별_포커스_시간_통계_조회_성공() throws Exception { + // given + User user = User.builder() + .email("jiwon@kakao.com") + .nickName("jiwon") + .provider("kakao") + .providerId("provider-id") + .role(UserRole.USER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(user); + + FocusRankDto.FocusBookResponseDto response = + new FocusRankDto.FocusBookResponseDto( + YearMonth.of(2026, 2), + 95, + List.of( + new FocusRankDto.FocusDateItem( + LocalDate.of(2026, 2, 1), + FocusTimeSlot.FOCUS_04 + ), + new FocusRankDto.FocusDateItem( + LocalDate.of(2026, 2, 2), + FocusTimeSlot.FOCUS_02 + ) + ) + ); + + given(libraryStatsService.viewFocusTimeStats(any(), any(YearMonth.class))) + .willReturn(response); + + // when & then + mockMvc.perform( + get("/api/library/stats/focus-monthly") + .param("yearMonth", "2026-02") + .header("Authorization", "Bearer test-access-token") + .with(user(userDetails)) + ) + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + queryParameters( + parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), + fieldWithPath("result.totalFocusMin").type(JsonFieldType.NUMBER).description("월 전체 포커스 시간(분)"), + fieldWithPath("result.focusBookItems").type(JsonFieldType.ARRAY).description("일자별 포커스 강도 목록"), + fieldWithPath("result.focusBookItems[].date").type(JsonFieldType.STRING).description("날짜"), + fieldWithPath("result.focusBookItems[].timeSlot").type(JsonFieldType.STRING).description("포커스 강도 슬롯 (FOCUS_00~FOCUS_04)") + )) + )); + } +} diff --git a/src/test/java/app/nook/focus/service/ThemeServiceTest.java b/src/test/java/app/nook/focus/service/ThemeServiceTest.java index 4afcd29..a6265c9 100644 --- a/src/test/java/app/nook/focus/service/ThemeServiceTest.java +++ b/src/test/java/app/nook/focus/service/ThemeServiceTest.java @@ -33,13 +33,13 @@ class ThemeServiceTest { @BeforeEach void setUp() { theme1 = Theme.builder() - .name(ThemeName.Theme1) + .name(ThemeName.THEME1) .imageUrl("https://cdn.nook.com/themes/theme1.png") .build(); ReflectionTestUtils.setField(theme1, "id", 1L); theme2 = Theme.builder() - .name(ThemeName.Theme2) + .name(ThemeName.THEME2) .imageUrl("https://cdn.nook.com/themes/theme2.png") .build(); ReflectionTestUtils.setField(theme2, "id", 2L); @@ -59,11 +59,11 @@ void setUp() { assertThat(result.themes()).hasSize(2); assertThat(result.themes().get(0).themeId()).isEqualTo(1L); - assertThat(result.themes().get(0).name()).isEqualTo("Theme1"); + assertThat(result.themes().get(0).name()).isEqualTo("THEME1"); assertThat(result.themes().get(0).imageUrl()).isEqualTo("https://cdn.nook.com/themes/theme1.png"); assertThat(result.themes().get(1).themeId()).isEqualTo(2L); - assertThat(result.themes().get(1).name()).isEqualTo("Theme2"); + assertThat(result.themes().get(1).name()).isEqualTo("THEME2"); assertThat(result.themes().get(1).imageUrl()).isEqualTo("https://cdn.nook.com/themes/theme2.png"); } diff --git a/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java new file mode 100644 index 0000000..4a586ee --- /dev/null +++ b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java @@ -0,0 +1,226 @@ +package app.nook.library.service; + +import app.nook.NookApplication; +import app.nook.book.domain.Book; +import app.nook.book.repository.BookRepository; +import app.nook.focus.repository.FocusRepository; +import app.nook.library.domain.Library; +import app.nook.library.repository.LibraryRepository; +import app.nook.timeline.repository.BookTimeLineRepository; +import app.nook.user.domain.User; +import app.nook.user.domain.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest( + classes = {NookApplication.class, LibraryCachingIntegrationTest.CacheTestConfig.class}, + properties = "spring.main.allow-bean-definition-overriding=true" +) +@ActiveProfiles("test") +class LibraryCachingIntegrationTest { + + @TestConfiguration + static class CacheTestConfig { + @Bean + @Primary + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager( + "libraryMonthlyCurrent", + "focusMonthlyCurrent", + "libraryStatusFirstPage" + ); + } + } + + @Autowired + private LibraryStatsService libraryStatsService; + + @Autowired + private LibraryService libraryService; + + @Autowired + private CacheManager cacheManager; + + @MockitoBean + private FocusRepository focusRepository; + + @MockitoBean + private BookRepository bookRepository; + + @MockitoBean + private LibraryRepository libraryRepository; + + @MockitoBean + private BookTimeLineRepository bookTimeLineRepository; + + @BeforeEach + void clearCaches() { + clearCache("libraryMonthlyCurrent"); + clearCache("focusMonthlyCurrent"); + clearCache("libraryStatusFirstPage"); + } + + @Test + void viewMonthly_동일키_호출시_한번만_조회된다() { + // given + Long userId = 1L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + given(focusRepository.findMonthlyFocusStats(eq(userId), eq(start), eq(end))) + .willReturn(List.of(monthlyProjection(LocalDate.of(2026, 2, 1), 10L, "cover-10", 1200L))); + + // when + libraryStatsService.viewMonthly(userId, yearMonth); + libraryStatsService.viewMonthly(userId, yearMonth); + + // then + verify(focusRepository, times(1)).findMonthlyFocusStats(eq(userId), eq(start), eq(end)); + } + + @Test + void deleteById_후_viewMonthly_재조회시_캐시가_무효화된다() { + // given + Long userId = 1L; + Long bookId = 100L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + User user = User.builder() + .email("user@test.com") + .nickName("user") + .provider("kakao") + .providerId("provider-id") + .role(UserRole.USER) + .build(); + ReflectionTestUtils.setField(user, "id", userId); + + Book book = Book.builder() + .isbn13("9780000000001") + .title("book") + .build(); + ReflectionTestUtils.setField(book, "id", bookId); + + Library library = new Library(user, book); + ReflectionTestUtils.setField(library, "id", 999L); + + given(focusRepository.findMonthlyFocusStats(eq(userId), eq(start), eq(end))) + .willReturn(List.of(monthlyProjection(LocalDate.of(2026, 2, 1), 10L, "cover-10", 1200L))); + + given(bookRepository.findById(bookId)).willReturn(Optional.of(book)); + given(libraryRepository.findByUserAndBook(user, book)).willReturn(library); + given(focusRepository.findDistinctFocusYearMonthsByLibraryAndUser(library.getId(), userId)) + .willReturn(List.of(yearMonthProjection(2026, 2))); + + // when + libraryStatsService.viewMonthly(userId, yearMonth); // cache put + libraryService.deleteById(user, bookId); // evict + libraryStatsService.viewMonthly(userId, yearMonth); // re-query + + // then + verify(focusRepository, times(2)).findMonthlyFocusStats(eq(userId), eq(start), eq(end)); + } + + @Test + void viewMonthly_키가_다르면_각각_캐시가_분리된다() { + // given + Long user1 = 1L; + Long user2 = 2L; + YearMonth feb = YearMonth.of(2026, 2); + YearMonth mar = YearMonth.of(2026, 3); + + LocalDateTime febStart = feb.atDay(1).atStartOfDay(); + LocalDateTime febEnd = feb.plusMonths(1).atDay(1).atStartOfDay(); + LocalDateTime marStart = mar.atDay(1).atStartOfDay(); + LocalDateTime marEnd = mar.plusMonths(1).atDay(1).atStartOfDay(); + + given(focusRepository.findMonthlyFocusStats(any(), any(), any())) + .willReturn(List.of(monthlyProjection(LocalDate.of(2026, 2, 1), 10L, "cover-10", 1200L))); + + // when + libraryStatsService.viewMonthly(user1, feb); + libraryStatsService.viewMonthly(user1, mar); + libraryStatsService.viewMonthly(user2, feb); + libraryStatsService.viewMonthly(user1, feb); // hit + + // then + verify(focusRepository, times(1)).findMonthlyFocusStats(eq(user1), eq(febStart), eq(febEnd)); + verify(focusRepository, times(1)).findMonthlyFocusStats(eq(user1), eq(marStart), eq(marEnd)); + verify(focusRepository, times(1)).findMonthlyFocusStats(eq(user2), eq(febStart), eq(febEnd)); + } + + private void clearCache(String cacheName) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + } + } + + private FocusRepository.MonthlyFocusStatsProjection monthlyProjection( + LocalDate dateValue, + Long bookId, + String coverImageUrl, + Long totalSec + ) { + return new FocusRepository.MonthlyFocusStatsProjection() { + @Override + public LocalDate getDateValue() { + return dateValue; + } + + @Override + public Long getBookId() { + return bookId; + } + + @Override + public String getCoverImageUrl() { + return coverImageUrl; + } + + @Override + public Long getTotalSec() { + return totalSec; + } + }; + } + + private FocusRepository.FocusYearMonthProjection yearMonthProjection(Integer year, Integer month) { + return new FocusRepository.FocusYearMonthProjection() { + @Override + public Integer getYearValue() { + return year; + } + + @Override + public Integer getMonthValue() { + return month; + } + }; + } +} diff --git a/src/test/java/app/nook/library/service/LibraryServiceTest.java b/src/test/java/app/nook/library/service/LibraryServiceTest.java index 3e4ea3b..96fcf8b 100644 --- a/src/test/java/app/nook/library/service/LibraryServiceTest.java +++ b/src/test/java/app/nook/library/service/LibraryServiceTest.java @@ -11,6 +11,7 @@ import app.nook.library.exception.LibraryErrorCode; import app.nook.library.repository.LibraryRepository; import app.nook.book.repository.BookRepository; +import app.nook.focus.repository.FocusRepository; import app.nook.timeline.repository.BookTimeLineRepository; import app.nook.user.domain.User; import app.nook.user.domain.enums.UserRole; @@ -19,6 +20,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; @@ -49,6 +51,12 @@ class LibraryServiceTest { @Mock private BookTimeLineRepository bookTimeLineRepository; + @Mock + private FocusRepository focusRepository; + + @Mock + private CacheManager cacheManager; + @InjectMocks private LibraryService libraryService; diff --git a/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java new file mode 100644 index 0000000..321c1e2 --- /dev/null +++ b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java @@ -0,0 +1,160 @@ +package app.nook.library.service; + +import app.nook.focus.repository.FocusRepository; +import app.nook.library.dto.FocusRankDto; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LibraryStatsServiceTest { + + @Mock + private FocusRepository focusRepository; + + @InjectMocks + private LibraryStatsService libraryStatsService; + + @Nested + class ViewMonthly { + + @Test + void 정상_케이스() { + // given + Long userId = 1L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + List rows = List.of( + row(LocalDate.of(2026, 2, 1), 11L, "cover-11", 1200L), + row(LocalDate.of(2026, 2, 2), 22L, "cover-22", 600L), + row(LocalDate.of(2026, 2, 3), 33L, "cover-33", 1800L) + ); + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(rows.stream().map(this::toProjection).toList()); + + // when + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + // then + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + assertThat(result.yearMonth()).isEqualTo(yearMonth); + assertThat(result.totalFocusMin()).isEqualTo((1200 + 600 + 1800) / 60); + assertThat(result.days()).hasSize(3); + + FocusRankDto.DailyBookItem day1 = result.days().get(0); + assertThat(day1.date()).isEqualTo(LocalDate.of(2026, 2, 1)); + assertThat(day1.bookCount()).isEqualTo(1L); + assertThat(day1.topBook().bookId()).isEqualTo(11L); + assertThat(day1.topBook().CoverUrl()).isEqualTo("cover-11"); + + FocusRankDto.DailyBookItem day2 = result.days().get(1); + assertThat(day2.date()).isEqualTo(LocalDate.of(2026, 2, 2)); + assertThat(day2.bookCount()).isEqualTo(1L); + assertThat(day2.topBook().bookId()).isEqualTo(22L); + + FocusRankDto.DailyBookItem day3 = result.days().get(2); + assertThat(day3.date()).isEqualTo(LocalDate.of(2026, 2, 3)); + assertThat(day3.bookCount()).isEqualTo(1L); + assertThat(day3.topBook().bookId()).isEqualTo(33L); + } + + @Test + void 동일_날짜에_여러_책_존재시_topBook_검증() { + // given + Long userId = 2L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + List rows = List.of( + row(LocalDate.of(2026, 2, 10), 100L, "cover-100", 300L), + row(LocalDate.of(2026, 2, 10), 200L, "cover-200", 1200L), + row(LocalDate.of(2026, 2, 11), 300L, "cover-300", 600L) + ); + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(rows.stream().map(this::toProjection).toList()); + + // when + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + // then + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + FocusRankDto.DailyBookItem sameDay = result.days().stream() + .filter(d -> d.date().equals(LocalDate.of(2026, 2, 10))) + .findFirst() + .orElseThrow(); + + assertThat(sameDay.bookCount()).isEqualTo(2L); + assertThat(sameDay.topBook().bookId()).isEqualTo(200L); + assertThat(sameDay.topBook().CoverUrl()).isEqualTo("cover-200"); + } + + @Test + void 포커스_기록이_없으면_빈_응답() { + // given + Long userId = 3L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(List.of()); + + // when + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + // then + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + assertThat(result.yearMonth()).isEqualTo(yearMonth); + assertThat(result.totalFocusMin()).isZero(); + assertThat(result.days()).isEmpty(); + } + + private FocusRankDto.MonthlyFocusRow row( + LocalDate date, + Long bookId, + String coverImageUrl, + Long totalSec + ) { + return new FocusRankDto.MonthlyFocusRow(date, bookId, coverImageUrl, totalSec); + } + + private FocusRepository.MonthlyFocusStatsProjection toProjection(FocusRankDto.MonthlyFocusRow row) { + return new FocusRepository.MonthlyFocusStatsProjection() { + @Override + public LocalDate getDateValue() { + return row.date(); + } + + @Override + public Long getBookId() { + return row.bookId(); + } + + @Override + public String getCoverImageUrl() { + return row.coverImageUrl(); + } + + @Override + public Long getTotalSec() { + return row.totalSec(); + } + }; + } + } +} From e0b52ce3d882d3b439bb73e802b75ed514c7f17c Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:13:06 +0900 Subject: [PATCH 06/14] =?UTF-8?q?[CHORE]=20html=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=93=A4=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7f3eaad..72508e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ # REST Docs build/generated-snippets/ +# Static resources +src/main/resources/static/docs/*.html + /src/main/resources/application-secret.properties db/postgres From cb110309f49a74c75e46bac89635ffbf48f8faf9 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:38:04 +0900 Subject: [PATCH 07/14] =?UTF-8?q?[CHORE]=20=EC=A4=91=EB=B3=B5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index efe39e7..714dfa2 100644 --- a/build.gradle +++ b/build.gradle @@ -62,9 +62,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-cache' - // cache - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' From fcbbfa7f35ca834fdce904f9813aceacaf728523 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:39:42 +0900 Subject: [PATCH 08/14] =?UTF-8?q?[REFACTOR]=20EXTRACT=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../focus/repository/FocusRepository.java | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/main/java/app/nook/focus/repository/FocusRepository.java b/src/main/java/app/nook/focus/repository/FocusRepository.java index f432bdc..6ff54fe 100644 --- a/src/main/java/app/nook/focus/repository/FocusRepository.java +++ b/src/main/java/app/nook/focus/repository/FocusRepository.java @@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -17,20 +16,31 @@ interface FocusYearMonthProjection { } interface MonthlyFocusStatsProjection { - LocalDate getDateValue(); + Integer getYearValue(); + Integer getMonthValue(); + Integer getDayValue(); Long getBookId(); String getCoverImageUrl(); Long getTotalSec(); } interface FocusTimeStatsProjection { - LocalDate getDateValue(); + Integer getYearValue(); + Integer getMonthValue(); + Integer getDayValue(); Long getTotalSec(); } + interface FocusRangeProjection { + LocalDateTime getStartedAt(); + LocalDateTime getEndedAt(); + } + @Query(""" select - FUNCTION('DATE', f.startedAt) as dateValue, + EXTRACT(YEAR FROM f.startedAt) as yearValue, + EXTRACT(MONTH FROM f.startedAt) as monthValue, + EXTRACT(DAY FROM f.startedAt) as dayValue, f.library.book.id as bookId, f.library.book.coverImageUrl as coverImageUrl, sum(f.durationSec) as totalSec @@ -39,7 +49,12 @@ interface FocusTimeStatsProjection { where l.user.id = :userId and f.startedAt >= :start and f.startedAt < :end - group by FUNCTION('DATE', f.startedAt), f.library.book.id, f.library.book.coverImageUrl + group by + EXTRACT(YEAR FROM f.startedAt), + EXTRACT(MONTH FROM f.startedAt), + EXTRACT(DAY FROM f.startedAt), + f.library.book.id, + f.library.book.coverImageUrl order by sum(f.durationSec) desc """) List findMonthlyFocusStats( @@ -50,8 +65,8 @@ List findMonthlyFocusStats( @Query(""" select distinct - YEAR(f.startedAt) as yearValue, - MONTH(f.startedAt) as monthValue + EXTRACT(YEAR FROM f.startedAt) as yearValue, + EXTRACT(MONTH FROM f.startedAt) as monthValue from Focus f where f.library.id = :libraryId and f.library.user.id = :userId @@ -65,14 +80,19 @@ List findDistinctFocusYearMonthsByLibraryAndUser( // 포커스 통계 조회 @Query(""" select - FUNCTION('DATE', f.startedAt) as dateValue, + EXTRACT(YEAR FROM f.startedAt) as yearValue, + EXTRACT(MONTH FROM f.startedAt) as monthValue, + EXTRACT(DAY FROM f.startedAt) as dayValue, sum(f.durationSec) as totalSec from Focus f join f.library l where l.user.id = :userId and f.startedAt >= :start and f.startedAt < :end - group by FUNCTION('DATE', f.startedAt) + group by + EXTRACT(YEAR FROM f.startedAt), + EXTRACT(MONTH FROM f.startedAt), + EXTRACT(DAY FROM f.startedAt) """) List findFocusTimeStats( @Param("userId") Long userId, @@ -80,4 +100,21 @@ List findFocusTimeStats( @Param("end") LocalDateTime end ); + @Query(""" + select + f.startedAt as startedAt, + f.endedAt as endedAt + from Focus f + join f.library l + where l.user.id = :userId + and f.endedAt is not null + and f.startedAt < :end + and f.endedAt > :start + """) + List findOverlappingFocusRanges( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + } From 667f8b882cbf778680fb965d675eaae981e1a258 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:40:23 +0900 Subject: [PATCH 09/14] =?UTF-8?q?[FEAT]=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/nook/global/config/CacheConfig.java | 7 ++- .../java/app/nook/library/domain/Library.java | 2 + .../app/nook/library/dto/FocusRankDto.java | 2 +- .../event/LibraryCacheInvalidateEvent.java | 25 ++++++++ .../LibraryCacheInvalidationListener.java | 43 +++++++++++++ .../library/repository/LibraryRepository.java | 5 -- .../nook/library/service/LibraryService.java | 60 +++++-------------- .../library/service/LibraryStatsService.java | 21 ++----- .../repository/BookTimeLineRepository.java | 1 + src/main/resources/application.yml | 1 - .../library/LibraryStatsControllerTest.java | 2 +- .../LibraryCachingIntegrationTest.java | 14 ++++- .../library/service/LibraryServiceTest.java | 4 ++ .../service/LibraryStatsServiceTest.java | 18 ++++-- 14 files changed, 128 insertions(+), 77 deletions(-) create mode 100644 src/main/java/app/nook/library/event/LibraryCacheInvalidateEvent.java create mode 100644 src/main/java/app/nook/library/event/LibraryCacheInvalidationListener.java diff --git a/src/main/java/app/nook/global/config/CacheConfig.java b/src/main/java/app/nook/global/config/CacheConfig.java index 61e874b..3c8ba2a 100644 --- a/src/main/java/app/nook/global/config/CacheConfig.java +++ b/src/main/java/app/nook/global/config/CacheConfig.java @@ -6,6 +6,9 @@ import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; import java.time.Duration; import java.util.HashMap; @@ -20,7 +23,9 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ofMinutes(5)); + .entryTtl(Duration.ofMinutes(5)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); Map cacheConfigs = new HashMap<>(); diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index f669767..e6c40d3 100644 --- a/src/main/java/app/nook/library/domain/Library.java +++ b/src/main/java/app/nook/library/domain/Library.java @@ -82,6 +82,8 @@ public void updateStatus(ReadingStatus readingStatus) { this.readingStatus = readingStatus; if(readingStatus.equals(ReadingStatus.FINISHED)){ this.endedAt = LocalDateTime.now().toLocalDate(); + } else if(readingStatus.equals(ReadingStatus.READING)){ + this.startedAt = LocalDateTime.now().toLocalDate(); } } diff --git a/src/main/java/app/nook/library/dto/FocusRankDto.java b/src/main/java/app/nook/library/dto/FocusRankDto.java index db69597..1cf5dc8 100644 --- a/src/main/java/app/nook/library/dto/FocusRankDto.java +++ b/src/main/java/app/nook/library/dto/FocusRankDto.java @@ -35,7 +35,7 @@ public record DailyBookItem( public record BookCalendarInfo( Long bookId, - String CoverUrl + String coverUrl ){} public record BookDetailInfo( diff --git a/src/main/java/app/nook/library/event/LibraryCacheInvalidateEvent.java b/src/main/java/app/nook/library/event/LibraryCacheInvalidateEvent.java new file mode 100644 index 0000000..6f31ab2 --- /dev/null +++ b/src/main/java/app/nook/library/event/LibraryCacheInvalidateEvent.java @@ -0,0 +1,25 @@ +package app.nook.library.event; + +import java.time.YearMonth; +import java.util.List; + +/** + * 캐시 무효화 이벤트를 처리하는 레코드 + * @param userId + * @param affectedYearMonths + * @param evictStatusFirstPage + */ +public record LibraryCacheInvalidateEvent( + Long userId, + List affectedYearMonths, + boolean evictStatusFirstPage +) { + // 상태 캐시 무효화 + public static LibraryCacheInvalidateEvent statusOnly(Long userId) { + return new LibraryCacheInvalidateEvent(userId, List.of(), true); + } + + public static LibraryCacheInvalidateEvent statusAndMonthly(Long userId, List affectedYearMonths) { + return new LibraryCacheInvalidateEvent(userId, affectedYearMonths, true); + } +} diff --git a/src/main/java/app/nook/library/event/LibraryCacheInvalidationListener.java b/src/main/java/app/nook/library/event/LibraryCacheInvalidationListener.java new file mode 100644 index 0000000..d9219b9 --- /dev/null +++ b/src/main/java/app/nook/library/event/LibraryCacheInvalidationListener.java @@ -0,0 +1,43 @@ +package app.nook.library.event; + +import app.nook.library.domain.enums.ReadingStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class LibraryCacheInvalidationListener { + + private final CacheManager cacheManager; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAfterCommit(LibraryCacheInvalidateEvent event) { + if (event.affectedYearMonths() != null && !event.affectedYearMonths().isEmpty()) { + Cache monthlyCache = cacheManager.getCache("libraryMonthlyCurrent"); + Cache focusTimeCache = cacheManager.getCache("focusMonthlyCurrent"); + for (var yearMonth : event.affectedYearMonths()) { + String key = event.userId() + ":" + yearMonth; + if (monthlyCache != null) { + monthlyCache.evict(key); + } + if (focusTimeCache != null) { + focusTimeCache.evict(key); + } + } + } + + if (event.evictStatusFirstPage()) { + Cache statusCache = cacheManager.getCache("libraryStatusFirstPage"); + if (statusCache == null) { + return; + } + for (ReadingStatus status : ReadingStatus.values()) { + statusCache.evict(event.userId() + ":" + status); + } + } + } +} diff --git a/src/main/java/app/nook/library/repository/LibraryRepository.java b/src/main/java/app/nook/library/repository/LibraryRepository.java index fc2e6da..cc495e0 100644 --- a/src/main/java/app/nook/library/repository/LibraryRepository.java +++ b/src/main/java/app/nook/library/repository/LibraryRepository.java @@ -3,7 +3,6 @@ import app.nook.book.domain.Book; import app.nook.library.domain.Library; import app.nook.library.domain.enums.ReadingStatus; -import app.nook.library.dto.FocusRankDto; import app.nook.user.domain.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -11,12 +10,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.security.core.parameters.P; -import java.time.LocalDate; -import java.time.YearMonth; import java.util.List; -import java.util.Optional; import java.util.Set; public interface LibraryRepository extends JpaRepository { diff --git a/src/main/java/app/nook/library/service/LibraryService.java b/src/main/java/app/nook/library/service/LibraryService.java index e6997ca..0071420 100644 --- a/src/main/java/app/nook/library/service/LibraryService.java +++ b/src/main/java/app/nook/library/service/LibraryService.java @@ -1,18 +1,17 @@ package app.nook.library.service; import app.nook.book.domain.Book; -import app.nook.book.dto.BookResponseDto; import app.nook.book.exception.BookErrorCode; import app.nook.book.repository.BookRepository; import app.nook.focus.repository.FocusRepository; import app.nook.global.dto.CursorResponse; import app.nook.global.exception.CustomException; -import app.nook.global.response.ErrorCode; import app.nook.library.converter.LibraryConverter; import app.nook.library.domain.Library; import app.nook.library.domain.enums.ReadingStatus; import app.nook.library.dto.LibraryViewDto; import app.nook.library.dto.ReadingStatusRequestDto; +import app.nook.library.event.LibraryCacheInvalidateEvent; import app.nook.library.exception.LibraryErrorCode; import app.nook.library.repository.LibraryRepository; import app.nook.timeline.converter.TimeLineConverter; @@ -21,9 +20,8 @@ import app.nook.timeline.repository.BookTimeLineRepository; import app.nook.user.domain.User; import lombok.RequiredArgsConstructor; -import org.springframework.cache.Cache; -import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -34,6 +32,7 @@ import java.time.YearMonth; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -44,7 +43,7 @@ public class LibraryService { private final BookRepository bookRepository; private final BookTimeLineRepository bookTimeLineRepository; private final FocusRepository focusRepository; - private final CacheManager cacheManager; + private final ApplicationEventPublisher eventPublisher; // 서재 책 개수 조회 @@ -70,7 +69,7 @@ public void save(User user,Long bookId) { savedLibrary.getId() ); bookTimeLineRepository.save(timeLine); - evictStatusBookFirstPageCaches(user.getId()); + eventPublisher.publishEvent(LibraryCacheInvalidateEvent.statusOnly(user.getId())); } // 서재 책 삭제 @@ -85,17 +84,20 @@ public void deleteById(User user, Long bookId){ throw new CustomException(LibraryErrorCode.BOOK_NOT_EXIST); // 캐시 무효화에 필요한 데이터 조회 - List affectedYearMonths = + List affectedYearMonths = focusRepository.findDistinctFocusYearMonthsByLibraryAndUser( library.getId(), user.getId() - ); + ).stream() + .map(ym -> YearMonth.of(ym.getYearValue(), ym.getMonthValue())) + .collect(Collectors.toList()); libraryRepository.delete(library); + bookTimeLineRepository.deleteByLibrary(library); - // 캐시 무효화 - evictMonthlyStatsCaches(user.getId(), affectedYearMonths); - evictStatusBookFirstPageCaches(user.getId()); + eventPublisher.publishEvent( + LibraryCacheInvalidateEvent.statusAndMonthly(user.getId(), affectedYearMonths) + ); } // 서재 책 상태변경 @@ -119,7 +121,7 @@ public void changeStatus(User user, ReadingStatusRequestDto requestDto) { library.getReadingStatus().toString(), library.getId()); bookTimeLineRepository.save(timeLine); - evictStatusBookFirstPageCaches(user.getId()); + eventPublisher.publishEvent(LibraryCacheInvalidateEvent.statusOnly(user.getId())); } // 서재 상태별 책 조회 @@ -173,38 +175,4 @@ public Page searchBooksInLibrary(Long userId, String keyword, int page, return libraryRepository.searchByUserIdAndKeyword(userId, escapedKeyword, pageable); } - // 캐시 무효화 월별 - private void evictMonthlyStatsCaches( - Long userId, - List yearMonths - ) { - if (yearMonths == null || yearMonths.isEmpty()) { - return; - } - Cache monthlyCache = cacheManager.getCache("libraryMonthlyCurrent"); - Cache focusTimeCache = cacheManager.getCache("focusMonthlyCurrent"); - - for (FocusRepository.FocusYearMonthProjection yearMonth : yearMonths) { - int year = yearMonth.getYearValue(); - int month = yearMonth.getMonthValue(); - String key = userId + ":" + YearMonth.of(year, month); - if (monthlyCache != null) { - monthlyCache.evict(key); - } - if (focusTimeCache != null) { - focusTimeCache.evict(key); - } - } - } - - // 기본 상태별 목록 캐시 무효화 - 첫 페이지 - private void evictStatusBookFirstPageCaches(Long userId) { - Cache cache = cacheManager.getCache("libraryStatusFirstPage"); - if (cache == null) { - return; - } - for (ReadingStatus status : ReadingStatus.values()) { - cache.evict(userId + ":" + status); - } - } } diff --git a/src/main/java/app/nook/library/service/LibraryStatsService.java b/src/main/java/app/nook/library/service/LibraryStatsService.java index 4622eee..8453197 100644 --- a/src/main/java/app/nook/library/service/LibraryStatsService.java +++ b/src/main/java/app/nook/library/service/LibraryStatsService.java @@ -2,7 +2,6 @@ import app.nook.focus.repository.FocusRepository; import app.nook.library.dto.FocusRankDto; -import app.nook.library.dto.FocusTimeSlot; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -39,7 +38,7 @@ public FocusRankDto.MonthlyBooksResponseDto viewMonthly(Long userId, YearMonth y List rows = focusRepository.findMonthlyFocusStats(userId, start, end) .stream() .map(row -> new FocusRankDto.MonthlyFocusRow( - row.getDateValue(), + LocalDate.of(row.getYearValue(), row.getMonthValue(), row.getDayValue()), row.getBookId(), row.getCoverImageUrl(), row.getTotalSec() @@ -88,40 +87,30 @@ public FocusRankDto.MonthlyBooksResponseDto viewMonthly(Long userId, YearMonth y return new FocusRankDto.MonthlyBooksResponseDto(yearMonth,totalFocusMin,dailyBookItems); } - // 서재 포커스 시간별 책 조회 + // 서재 월별 포커스 시간 통계 조회 @Cacheable( value = "focusMonthlyCurrent", key = "#userId + ':' + #yearMonth" ) public FocusRankDto.FocusBookResponseDto viewFocusTimeStats(Long userId, YearMonth yearMonth) { - // 월 범위 계산 LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime end = yearMonth.plusMonths(1) - .atDay(1) - .atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - // 집계 결과 받아오기 List rows = focusRepository.findFocusTimeStats(userId, start, end) .stream() .map(row -> new FocusRankDto.FocusTimeRow( - row.getDateValue(), + LocalDate.of(row.getYearValue(), row.getMonthValue(), row.getDayValue()), row.getTotalSec() )) .toList(); - // 월 전체 초를 분으로 변환 - long totalSec = rows.stream() - .mapToLong(FocusRankDto.FocusTimeRow::totalSec) - .sum(); + long totalSec = rows.stream().mapToLong(FocusRankDto.FocusTimeRow::totalSec).sum(); int totalFocusMin = (int) (totalSec / 60); - - // 가장 많이 포커스한 날짜 초 단위 long maxDaySec = rows.stream() .mapToLong(FocusRankDto.FocusTimeRow::totalSec) .max() .orElse(0L); - // 날짜별로 FocusDateItem 매핑 List focusBookItems = rows.stream() .sorted(Comparator.comparing(FocusRankDto.FocusTimeRow::date)) .map(row -> new FocusRankDto.FocusDateItem( diff --git a/src/main/java/app/nook/timeline/repository/BookTimeLineRepository.java b/src/main/java/app/nook/timeline/repository/BookTimeLineRepository.java index 652019f..08d730f 100644 --- a/src/main/java/app/nook/timeline/repository/BookTimeLineRepository.java +++ b/src/main/java/app/nook/timeline/repository/BookTimeLineRepository.java @@ -7,4 +7,5 @@ import java.util.List; public interface BookTimeLineRepository extends JpaRepository { + void deleteByLibrary(Library library); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e0d389..458721d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,7 +19,6 @@ spring: format_sql: true show_sql: false dialect: org.hibernate.dialect.PostgreSQLDialect - generate_statistics: true open-in-view: false cache: type: redis diff --git a/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java index 9df3e98..af38191 100644 --- a/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java +++ b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java @@ -85,7 +85,7 @@ class LibraryStatsControllerTest extends AbstractRestDocsTests { fieldWithPath("result.days[].bookCount").type(JsonFieldType.NUMBER).description("해당 날짜에 읽은 책 수"), fieldWithPath("result.days[].topBook").type(JsonFieldType.OBJECT).optional().description("가장 오래 읽은 책"), fieldWithPath("result.days[].topBook.bookId").type(JsonFieldType.NUMBER).optional().description("도서 ID"), - fieldWithPath("result.days[].topBook.CoverUrl").type(JsonFieldType.STRING).optional().description("도서 커버 URL") + fieldWithPath("result.days[].topBook.coverUrl").type(JsonFieldType.STRING).optional().description("도서 커버 URL") )) )); } diff --git a/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java index 4a586ee..382892c 100644 --- a/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java +++ b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java @@ -189,8 +189,18 @@ private FocusRepository.MonthlyFocusStatsProjection monthlyProjection( ) { return new FocusRepository.MonthlyFocusStatsProjection() { @Override - public LocalDate getDateValue() { - return dateValue; + public Integer getYearValue() { + return dateValue.getYear(); + } + + @Override + public Integer getMonthValue() { + return dateValue.getMonthValue(); + } + + @Override + public Integer getDayValue() { + return dateValue.getDayOfMonth(); } @Override diff --git a/src/test/java/app/nook/library/service/LibraryServiceTest.java b/src/test/java/app/nook/library/service/LibraryServiceTest.java index 96fcf8b..b8e3059 100644 --- a/src/test/java/app/nook/library/service/LibraryServiceTest.java +++ b/src/test/java/app/nook/library/service/LibraryServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.cache.CacheManager; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; @@ -57,6 +58,9 @@ class LibraryServiceTest { @Mock private CacheManager cacheManager; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private LibraryService libraryService; diff --git a/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java index 321c1e2..a0d54a1 100644 --- a/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java +++ b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java @@ -59,7 +59,7 @@ class ViewMonthly { assertThat(day1.date()).isEqualTo(LocalDate.of(2026, 2, 1)); assertThat(day1.bookCount()).isEqualTo(1L); assertThat(day1.topBook().bookId()).isEqualTo(11L); - assertThat(day1.topBook().CoverUrl()).isEqualTo("cover-11"); + assertThat(day1.topBook().coverUrl()).isEqualTo("cover-11"); FocusRankDto.DailyBookItem day2 = result.days().get(1); assertThat(day2.date()).isEqualTo(LocalDate.of(2026, 2, 2)); @@ -100,7 +100,7 @@ class ViewMonthly { assertThat(sameDay.bookCount()).isEqualTo(2L); assertThat(sameDay.topBook().bookId()).isEqualTo(200L); - assertThat(sameDay.topBook().CoverUrl()).isEqualTo("cover-200"); + assertThat(sameDay.topBook().coverUrl()).isEqualTo("cover-200"); } @Test @@ -136,8 +136,18 @@ private FocusRankDto.MonthlyFocusRow row( private FocusRepository.MonthlyFocusStatsProjection toProjection(FocusRankDto.MonthlyFocusRow row) { return new FocusRepository.MonthlyFocusStatsProjection() { @Override - public LocalDate getDateValue() { - return row.date(); + public Integer getYearValue() { + return row.date().getYear(); + } + + @Override + public Integer getMonthValue() { + return row.date().getMonthValue(); + } + + @Override + public Integer getDayValue() { + return row.date().getDayOfMonth(); } @Override From 2234cb9dfcf70235cc2d22831a8b03b85cef4705 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:01:08 +0900 Subject: [PATCH 10/14] =?UTF-8?q?[BUILD]=20jacoco=20=EC=A0=9C=EC=99=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=93=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 714dfa2..a4e44d9 100644 --- a/build.gradle +++ b/build.gradle @@ -170,19 +170,39 @@ test { jacocoTestCoverageVerification { violationRules { rule { + element = 'CLASS' + includes = ['app.nook.*.service.*'] + excludes = [ + 'app.nook.*.service.*Test*', + 'app.nook.user.service.CustomUserDetailService', + 'app.nook.**.exception.**', + 'app.nook.**.*ErrorCode*', + 'app.nook.**.enums.**' + ] limit { counter = 'LINE' value = 'COVEREDRATIO' minimum = 0.60 } } + rule { + element = 'CLASS' + includes = ['app.nook.*.controller.*'] + excludes = [ + 'app.nook.*.controller.*Test*', + 'app.nook.**.exception.**', + 'app.nook.**.*ErrorCode*', + 'app.nook.**.enums.**' + ] + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.40 + } + } } } -jacocoTestCoverageVerification { - enabled = false // 임시 -} - sourceSets { main { From b4c1eea7bfdefa3cf88985d33664202e4e319b02 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:01:42 +0900 Subject: [PATCH 11/14] =?UTF-8?q?[FEAT]=20=EC=84=9C=EC=9E=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/nook/library/domain/QLibrary.java | 4 +- .../focus/repository/FocusRepository.java | 18 ++++++++ .../library/controller/LibraryController.java | 30 ++++++++++++ .../java/app/nook/library/domain/Library.java | 16 +++++-- .../app/nook/library/dto/FocusRankDto.java | 4 +- .../app/nook/library/dto/LibraryViewDto.java | 10 ++-- .../library/repository/LibraryRepository.java | 1 + .../nook/library/service/LibraryService.java | 46 +++++++++++++++++++ .../library/service/LibraryStatsService.java | 8 +--- .../nook/timeline/domain/BookTimeLine.java | 2 +- 10 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/main/generated/app/nook/library/domain/QLibrary.java b/src/main/generated/app/nook/library/domain/QLibrary.java index c4afdda..6bf643b 100644 --- a/src/main/generated/app/nook/library/domain/QLibrary.java +++ b/src/main/generated/app/nook/library/domain/QLibrary.java @@ -31,13 +31,15 @@ public class QLibrary extends EntityPathBase { public final DatePath endedAt = createDate("endedAt", java.time.LocalDate.class); - public final NumberPath focusMin = createNumber("focusMin", Long.class); + public final NumberPath focusSec = createNumber("focusSec", Long.class); public final NumberPath id = createNumber("id", Long.class); //inherited public final DateTimePath modifiedDate = _super.modifiedDate; + public final NumberPath page = createNumber("page", Integer.class); + public final EnumPath readingStatus = createEnum("readingStatus", app.nook.library.domain.enums.ReadingStatus.class); public final DatePath startedAt = createDate("startedAt", java.time.LocalDate.class); diff --git a/src/main/java/app/nook/focus/repository/FocusRepository.java b/src/main/java/app/nook/focus/repository/FocusRepository.java index 6ff54fe..1650258 100644 --- a/src/main/java/app/nook/focus/repository/FocusRepository.java +++ b/src/main/java/app/nook/focus/repository/FocusRepository.java @@ -1,6 +1,9 @@ package app.nook.focus.repository; import app.nook.focus.domain.Focus; +import app.nook.user.domain.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -117,4 +120,19 @@ List findOverlappingFocusRanges( @Param("end") LocalDateTime end ); + @Query(""" + select f + from Focus f + where f.library.user = :user + and (:cursor is null or f.id < :cursor) + and f.startedAt >= :start + and f.startedAt < :end + order by f.id desc + """) + Slice findByLibraryWithCursorByDate( + @Param("user") User user, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("cursor") Long cursor, + Pageable pageable); } diff --git a/src/main/java/app/nook/library/controller/LibraryController.java b/src/main/java/app/nook/library/controller/LibraryController.java index 448d616..db6f0d9 100644 --- a/src/main/java/app/nook/library/controller/LibraryController.java +++ b/src/main/java/app/nook/library/controller/LibraryController.java @@ -2,6 +2,7 @@ import app.nook.global.response.ApiResponse; import app.nook.global.response.SuccessCode; +import app.nook.global.dto.CursorResponse; import app.nook.library.domain.enums.ReadingStatus; import app.nook.library.dto.ReadingStatusRequestDto; import app.nook.library.dto.LibraryViewDto; @@ -11,10 +12,13 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @RestController @RequiredArgsConstructor @RequestMapping("/api/library") @@ -65,4 +69,30 @@ public ApiResponse viewBooksByStatus( libraryService.viewBooksByStatus(userDetails.getUser(), status, cursor, size); return ApiResponse.onSuccess(response, SuccessCode.OK); } + + // 서재 책 개수 조회 + @GetMapping("/count") + public ApiResponse viewBookCount( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + LibraryViewDto.BookCountResponseDto response = + libraryService.countBooks(userDetails.getUser()); + return ApiResponse.onSuccess(response, SuccessCode.OK); + } + + // 해당 날짜의 포커스 기록 반환 + @GetMapping("/focus-records") + public ApiResponse> viewFocusRecordByDate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam + @NotNull + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate date, + @RequestParam(required = false) @Min(0) Long cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + CursorResponse response = + libraryService.viewFocusRecordByDate(userDetails.getUser(), date, cursor, size); + return ApiResponse.onSuccess(response, SuccessCode.OK); + } } diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index e6c40d3..44bd5e5 100644 --- a/src/main/java/app/nook/library/domain/Library.java +++ b/src/main/java/app/nook/library/domain/Library.java @@ -57,8 +57,11 @@ public class Library extends BaseEntity { @Column(name = "ended_at") private LocalDate endedAt; - @Column(name = "focus_min") - private Long focusMin = 0L; + @Column(name = "focus_sec") + private Long focusSec = 0L; + + @Column(name = "page") + private int page = 0; @Builder public Library( @@ -72,8 +75,13 @@ public Library( // 포커스 시 읽은 기록 업데이트 - public void recordFocus(long addedMinutes) { - this.focusMin += addedMinutes; + public void recordFocus(long addedSeconds) { + this.focusSec += addedSeconds; + } + + // 페이지 업데이트 + public void recordPage(int page) { + this.page = page; } // 완독 -> 독서 중 diff --git a/src/main/java/app/nook/library/dto/FocusRankDto.java b/src/main/java/app/nook/library/dto/FocusRankDto.java index 1cf5dc8..35797b2 100644 --- a/src/main/java/app/nook/library/dto/FocusRankDto.java +++ b/src/main/java/app/nook/library/dto/FocusRankDto.java @@ -22,7 +22,7 @@ public record FocusDateItem( // 월 통계 public record MonthlyBooksResponseDto( YearMonth yearMonth, - int totalFocusMin, + int totalBookCount, List days ){} @@ -43,7 +43,7 @@ public record BookDetailInfo( String coverUrl, String title, String author, - Long focusMin + Long focusSec ) {} // 중간 row dto diff --git a/src/main/java/app/nook/library/dto/LibraryViewDto.java b/src/main/java/app/nook/library/dto/LibraryViewDto.java index c558d51..e0febe9 100644 --- a/src/main/java/app/nook/library/dto/LibraryViewDto.java +++ b/src/main/java/app/nook/library/dto/LibraryViewDto.java @@ -27,16 +27,11 @@ public record MonthlyBookItem( List monthlyBookItems ){} - public record DailyBookResponse( - LocalDate localDate, - CursorResponse bookItems - ){} - public record UserBookResponseDto( Long bookId, String title, String author, - int focusMin, + int focusSec, String coverUrl ){} public record StatusBookResponseDto( @@ -79,4 +74,7 @@ public record FinishedBookItem( LocalDate endedAt ) implements UserStatusBookItem {} + public record BookCountResponseDto( + int totalBookNum + ){} } diff --git a/src/main/java/app/nook/library/repository/LibraryRepository.java b/src/main/java/app/nook/library/repository/LibraryRepository.java index cc495e0..48ea867 100644 --- a/src/main/java/app/nook/library/repository/LibraryRepository.java +++ b/src/main/java/app/nook/library/repository/LibraryRepository.java @@ -57,4 +57,5 @@ Page searchByUserIdAndKeyword( @Param("keyword") String keyword, Pageable pageable); + int countByUser(User user); } diff --git a/src/main/java/app/nook/library/service/LibraryService.java b/src/main/java/app/nook/library/service/LibraryService.java index 0071420..065591e 100644 --- a/src/main/java/app/nook/library/service/LibraryService.java +++ b/src/main/java/app/nook/library/service/LibraryService.java @@ -3,6 +3,7 @@ import app.nook.book.domain.Book; import app.nook.book.exception.BookErrorCode; import app.nook.book.repository.BookRepository; +import app.nook.focus.domain.Focus; import app.nook.focus.repository.FocusRepository; import app.nook.global.dto.CursorResponse; import app.nook.global.exception.CustomException; @@ -29,6 +30,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.YearMonth; import java.util.List; import java.util.Set; @@ -47,6 +50,10 @@ public class LibraryService { // 서재 책 개수 조회 + // 서재 책은 최대 100000권이므로 int로 반환 타입 설정 + public LibraryViewDto.BookCountResponseDto countBooks(User user) { + return new LibraryViewDto.BookCountResponseDto(libraryRepository.countByUser(user)); + } // 서재 책 등록 @Transactional @@ -175,4 +182,43 @@ public Page searchBooksInLibrary(Long userId, String keyword, int page, return libraryRepository.searchByUserIdAndKeyword(userId, escapedKeyword, pageable); } + + // 날짜별 포커스 기록 조회 커서 페이징 + public CursorResponse viewFocusRecordByDate( + User user, + LocalDate date, + Long cursor, + int size + ) { + Pageable pageable = PageRequest.of(0, size + 1); + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.plusDays(1).atStartOfDay(); + + Slice focuses = focusRepository.findByLibraryWithCursorByDate( + user, + start, + end, + cursor, + pageable + ); + + List content = focuses.getContent(); + boolean hasNext = content.size() > size; + List pageContent = hasNext ? content.subList(0, size) : content; + + List bookItems = pageContent.stream() + .map(focus -> new LibraryViewDto.UserBookResponseDto( + focus.getLibrary().getBook().getId(), + focus.getLibrary().getBook().getTitle(), + focus.getLibrary().getBook().getAuthor(), + focus.getDurationSec() == null ? 0 : focus.getDurationSec(), + focus.getLibrary().getBook().getCoverImageUrl() + )) + .toList(); + + Long nextCursor = hasNext ? pageContent.get(pageContent.size() - 1).getId() : null; + + return CursorResponse.of(bookItems, nextCursor, hasNext); + } + } diff --git a/src/main/java/app/nook/library/service/LibraryStatsService.java b/src/main/java/app/nook/library/service/LibraryStatsService.java index 8453197..547250b 100644 --- a/src/main/java/app/nook/library/service/LibraryStatsService.java +++ b/src/main/java/app/nook/library/service/LibraryStatsService.java @@ -45,11 +45,7 @@ public FocusRankDto.MonthlyBooksResponseDto viewMonthly(Long userId, YearMonth y )) .toList(); - // 월 전체 초를 분으로 변환 - long totalSec = rows.stream() - .mapToLong(FocusRankDto.MonthlyFocusRow::totalSec) - .sum(); - int totalFocusMin = (int) (totalSec / 60); + int totalBookCount = rows.size(); // 날짜별로 다시 그룹화 Map> groupedByDate = rows.stream() @@ -84,7 +80,7 @@ public FocusRankDto.MonthlyBooksResponseDto viewMonthly(Long userId, YearMonth y }) .sorted(Comparator.comparing(FocusRankDto.DailyBookItem::date)) // 오름차순 정렬 .toList(); - return new FocusRankDto.MonthlyBooksResponseDto(yearMonth,totalFocusMin,dailyBookItems); + return new FocusRankDto.MonthlyBooksResponseDto(yearMonth, totalBookCount, dailyBookItems); } // 서재 월별 포커스 시간 통계 조회 diff --git a/src/main/java/app/nook/timeline/domain/BookTimeLine.java b/src/main/java/app/nook/timeline/domain/BookTimeLine.java index dc22d5c..1d806f7 100644 --- a/src/main/java/app/nook/timeline/domain/BookTimeLine.java +++ b/src/main/java/app/nook/timeline/domain/BookTimeLine.java @@ -18,7 +18,7 @@ indexes = { @Index( name = "idx_book_timelines_library_created", - columnList = "library_id, created_at DESC" + columnList = "library_id, created_date DESC" ), } ) From 2d4edb3d176fab87c3370b316fa2f7bb0c8c12cb Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:02:00 +0900 Subject: [PATCH 12/14] =?UTF-8?q?[TEST]=20spring=20security=20test=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/book/BookControllerTest.java | 71 ++-- .../book/BookSearchControllerTest.java | 84 +++-- .../library/LibraryControllerTest.java | 208 +++++++---- .../library/LibraryStatsControllerTest.java | 263 ++++++++------ .../controller/user/AuthControllerTest.java | 22 +- .../global/common/AbstractRestDocsTests.java | 16 +- .../common/AbstractWebMvcRestDocsTests.java | 80 +++++ .../global/common/TestSecurityConfig.java | 32 ++ .../common/security/WithCustomUser.java | 20 ++ .../WithCustomUserSecurityContextFactory.java | 37 ++ .../service/LibraryStatsServiceTest.java | 326 +++++++++++------- 11 files changed, 774 insertions(+), 385 deletions(-) create mode 100644 src/test/java/app/nook/global/common/AbstractWebMvcRestDocsTests.java create mode 100644 src/test/java/app/nook/global/common/TestSecurityConfig.java create mode 100644 src/test/java/app/nook/global/common/security/WithCustomUser.java create mode 100644 src/test/java/app/nook/global/common/security/WithCustomUserSecurityContextFactory.java diff --git a/src/test/java/app/nook/controller/book/BookControllerTest.java b/src/test/java/app/nook/controller/book/BookControllerTest.java index 06126b9..a3ff57d 100644 --- a/src/test/java/app/nook/controller/book/BookControllerTest.java +++ b/src/test/java/app/nook/controller/book/BookControllerTest.java @@ -2,22 +2,23 @@ import app.nook.book.domain.enums.MallType; import app.nook.book.domain.enums.SourceType; +import app.nook.book.controller.BookController; import app.nook.book.dto.BookResponseDto; import app.nook.book.exception.BookErrorCode; import app.nook.book.service.BookService; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; import app.nook.global.exception.CustomException; -import app.nook.global.response.ErrorCode; -import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.util.ReflectionTestUtils; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import java.util.Arrays; import java.util.Collections; @@ -31,32 +32,27 @@ 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class BookControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = BookController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +public class BookControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private BookService bookService; - private User user; - private CustomUserDetails userDetails; - - @BeforeEach - void setUpUser() { - user = User.builder() - .email("test@example.com") - .nickName("테스터") - .provider("google") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - ReflectionTestUtils.setField(user, "id", 1L); - userDetails = new CustomUserDetails(user); - } - @Test + @WithCustomUser @DisplayName("ISBN으로 도서 상세 조회 성공") void 도서_상세조회_성공() throws Exception { // given @@ -80,14 +76,13 @@ void setUpUser() { .bookShelfId(null) .build(); - given(bookService.getBookDetailByIsbn(any(User.class), eq(isbn13))) + given(bookService.getBookDetailByIsbn(any(), eq(isbn13))) .willReturn(response); // when & then mockMvc.perform( get("/api/books/{isbn13}", isbn13) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.title").value("채식주의자")) @@ -119,6 +114,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("잘못된 ISBN 형식으로 조회 시 400 Bad Request") void 도서_상세조회_실패_잘못된_ISBN_형식() throws Exception { // given @@ -127,31 +123,31 @@ void setUpUser() { // when & then mockMvc.perform( get("/api/books/{isbn13}", invalidIsbn) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); } @Test + @WithCustomUser @DisplayName("존재하지 않는 도서 조회 시 404 Not Found") void 도서_상세조회_실패_존재하지않는_도서() throws Exception { // given String isbn13 = "9999999999999"; - given(bookService.getBookDetailByIsbn(any(User.class), eq(isbn13))) + given(bookService.getBookDetailByIsbn(any(), eq(isbn13))) .willThrow(new CustomException(BookErrorCode.BOOK_NOT_FOUND)); // when & then mockMvc.perform( get("/api/books/{isbn13}", isbn13) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isNotFound()); } @Test + @WithCustomUser @DisplayName("주간 베스트셀러 목록 조회 성공") void 주간베스트셀러_조회_성공() throws Exception { // given @@ -180,8 +176,7 @@ void setUpUser() { // when & then mockMvc.perform( get("/api/books/bestsellers") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result[0].title").value("채식주의자")) @@ -201,6 +196,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("사용자 맞춤 추천 베스트셀러 조회 성공") void 추천베스트셀러_조회_성공() throws Exception { // given @@ -222,8 +218,7 @@ void setUpUser() { // when & then mockMvc.perform( get("/api/books/recommendations") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result[0].title").value("채식주의자")) diff --git a/src/test/java/app/nook/controller/book/BookSearchControllerTest.java b/src/test/java/app/nook/controller/book/BookSearchControllerTest.java index 8e2d1ce..8038986 100644 --- a/src/test/java/app/nook/controller/book/BookSearchControllerTest.java +++ b/src/test/java/app/nook/controller/book/BookSearchControllerTest.java @@ -1,18 +1,21 @@ package app.nook.controller.book; import app.nook.book.domain.enums.SearchType; +import app.nook.book.controller.BookSearchController; import app.nook.book.dto.BookResponseDto; import app.nook.book.facade.BookSearchFacade; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; -import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; -import org.junit.jupiter.api.BeforeEach; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.util.ReflectionTestUtils; import java.util.ArrayList; import java.util.Arrays; @@ -26,11 +29,21 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class BookSearchControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = BookSearchController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +class BookSearchControllerTest extends AbstractWebMvcRestDocsTests { private static final Long TEST_USER_ID = 1L; private static final String TEST_KEYWORD = "채식주의자"; @@ -40,22 +53,8 @@ class BookSearchControllerTest extends AbstractRestDocsTests { @MockitoBean private BookSearchFacade bookSearchFacade; - private CustomUserDetails userDetails; - - @BeforeEach - void setUpUser() { - User user = User.builder() - .email("test@example.com") - .nickName("테스터") - .provider("google") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - ReflectionTestUtils.setField(user, "id", TEST_USER_ID); - userDetails = new CustomUserDetails(user); - } - @Test + @WithCustomUser @DisplayName("도서 검색 성공 - 첫 검색 (cursor=null)") void 도서검색_첫검색_성공() throws Exception { // given @@ -68,8 +67,7 @@ void setUpUser() { mockMvc.perform( get("/api/books/search/{type}", "GLOBAL") .param("keyword", TEST_KEYWORD) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.totalResults").value(2)) @@ -107,6 +105,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("도서 검색 성공 - 페이지네이션 (cursor 사용)") void 도서검색_페이지네이션_성공() throws Exception { // given @@ -121,40 +120,40 @@ void setUpUser() { get("/api/books/search/{type}", "GLOBAL") .param("keyword", TEST_KEYWORD) .param("cursor", String.valueOf(cursor)) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.nextCursor").value(4)); } @Test + @WithCustomUser @DisplayName("도서 검색 실패 - 빈 검색어") void 도서검색_빈검색어_실패() throws Exception { // when & then mockMvc.perform( get("/api/books/search/{type}", "GLOBAL") .param("keyword", "") // 빈 문자열 - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); } @Test + @WithCustomUser @DisplayName("도서 검색 실패 - 검색어 누락") void 도서검색_검색어누락_실패() throws Exception { // when & then mockMvc.perform( get("/api/books/search/{type}", "GLOBAL") // keyword 파라미터 없음 - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); } @Test + @WithCustomUser @DisplayName("도서 검색 실패 - 음수 커서") void 도서검색_음수커서_실패() throws Exception { // when & then @@ -162,26 +161,26 @@ void setUpUser() { get("/api/books/search/{type}", "GLOBAL") .param("keyword", TEST_KEYWORD) .param("cursor", "-1") // 음수 - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); } @Test + @WithCustomUser @DisplayName("도서 검색 실패 - 잘못된 검색 타입") void 도서검색_잘못된타입_실패() throws Exception { // when & then mockMvc.perform( get("/api/books/search/{type}", "INVALID_TYPE") .param("keyword", TEST_KEYWORD) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); // TypeMismatchException 발생 } @Test + @WithCustomUser @DisplayName("검색 기록 조회 성공") void 검색기록조회_성공() throws Exception { // given @@ -193,8 +192,7 @@ void setUpUser() { // when & then mockMvc.perform( get("/api/books/search/{type}/histories", "GLOBAL") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.result").isArray()) @@ -214,6 +212,7 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("특정 검색어 삭제 성공") void 검색기록삭제_성공() throws Exception { // given @@ -223,8 +222,7 @@ void setUpUser() { // when & then mockMvc.perform( delete("/api/books/search/{type}/histories?keyword={keyword}", "GLOBAL", TEST_KEYWORD) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andDo(documentWithAuth( @@ -244,19 +242,20 @@ void setUpUser() { } @Test + @WithCustomUser @DisplayName("검색어 삭제 실패 - 검색어 누락") void 검색기록삭제_검색어누락_실패() throws Exception { // when & then mockMvc.perform( delete("/api/books/search/{type}/histories", "GLOBAL") // keyword 파라미터 없음 - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isBadRequest()); } @Test + @WithCustomUser @DisplayName("전체 검색 기록 삭제 성공") void 검색기록전체삭제_성공() throws Exception { // given @@ -266,8 +265,7 @@ void setUpUser() { // when & then mockMvc.perform( delete("/api/books/search/{type}/histories/all", "GLOBAL") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andDo(documentWithAuth( diff --git a/src/test/java/app/nook/controller/library/LibraryControllerTest.java b/src/test/java/app/nook/controller/library/LibraryControllerTest.java index e8d660b..dc398dc 100644 --- a/src/test/java/app/nook/controller/library/LibraryControllerTest.java +++ b/src/test/java/app/nook/controller/library/LibraryControllerTest.java @@ -1,19 +1,26 @@ package app.nook.controller.library; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; import app.nook.global.dto.CursorResponse; import app.nook.library.domain.enums.ReadingStatus; +import app.nook.library.controller.LibraryController; import app.nook.library.dto.LibraryViewDto; import app.nook.library.dto.ReadingStatusRequestDto; import app.nook.library.service.LibraryService; -import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.LocalDate; @@ -34,11 +41,22 @@ 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.restdocs.payload.JsonFieldType.*; -class LibraryControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = LibraryController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +class LibraryControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private LibraryService libraryService; @@ -47,25 +65,15 @@ class LibraryControllerTest extends AbstractRestDocsTests { ObjectMapper objectMapper; @Test + @WithCustomUser void 서재_책_등록_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - willDoNothing().given(libraryService).save(any(), anyLong()); // when & then mockMvc.perform( post("/api/library/{bookId}", 1L) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) + .with(csrf()) ) .andExpect(status().isOk()) .andDo(documentWithAuth( @@ -78,25 +86,15 @@ class LibraryControllerTest extends AbstractRestDocsTests { } @Test + @WithCustomUser void 서재_책_삭제_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - willDoNothing().given(libraryService).deleteById(any(), anyLong()); // when & then mockMvc.perform( delete("/api/library/{bookId}", 1L) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) + .with(csrf()) ) .andExpect(status().isOk()) .andDo(documentWithAuth( @@ -109,18 +107,8 @@ class LibraryControllerTest extends AbstractRestDocsTests { } @Test + @WithCustomUser void 서재_책_상태변경_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - ReadingStatusRequestDto request = new ReadingStatusRequestDto(1L, ReadingStatus.READING); willDoNothing().given(libraryService).changeStatus(any(), any()); @@ -130,8 +118,8 @@ class LibraryControllerTest extends AbstractRestDocsTests { patch("/api/library/status") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) + .with(csrf()) ) .andExpect(status().isOk()) .andDo(documentWithAuth( @@ -145,18 +133,8 @@ class LibraryControllerTest extends AbstractRestDocsTests { } @Test + @WithCustomUser void 서재_상태별_책_조회_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - LibraryViewDto.UserStatusBookItem item = new LibraryViewDto.ReadingBookItem( 1L, @@ -185,8 +163,7 @@ class LibraryControllerTest extends AbstractRestDocsTests { .param("status", "READING") .param("cursor", "10") .param("size", "2") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) + .header(AUTH_HEADER, AUTH_TOKEN) ) .andExpect(status().isOk()) .andDo(documentWithAuth( @@ -212,4 +189,119 @@ class LibraryControllerTest extends AbstractRestDocsTests { )) )); } + + @DisplayName("서재 책 개수 조회") + @Nested + class ViewBookCount { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("서재 책 개수 조회") + @WithCustomUser + void viewBookCount() throws Exception { + given(libraryService.countBooks(any())) + .willReturn(new LibraryViewDto.BookCountResponseDto(42)); + + mockMvc.perform( + get("/api/library/count") + .header(AUTH_HEADER, AUTH_TOKEN) + ) + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.totalBookNum").type(NUMBER).description("서재 전체 책 수") + )) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("인증 정보가 없으면 401") + void viewBookCountWithoutAuth() throws Exception { + mockMvc.perform(get("/api/library/count")) + .andExpect(status().isUnauthorized()); + } + } + } + + @DisplayName("날짜별 포커스 기록 조회") + @Nested + class ViewFocusRecordByDate { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("해당 날짜 포커스 기록 조회") + @WithCustomUser + void focusRecordByDate() throws Exception { + LibraryViewDto.UserBookResponseDto item = new LibraryViewDto.UserBookResponseDto( + 1L, + "타이틀", + "작가", + 1800, + "https://example.com/cover.jpg" + ); + + CursorResponse response = + CursorResponse.of(List.of(item), 77L, true); + + given(libraryService.viewFocusRecordByDate(any(), any(LocalDate.class), any(), anyInt())) + .willReturn(response); + + mockMvc.perform( + get("/api/library/focus-records") + .param("date", "2026-02-26") + .param("cursor", "100") + .param("size", "20") + .header(AUTH_HEADER, AUTH_TOKEN) + ) + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + queryParameters( + parameterWithName("date").description("조회 날짜 (yyyy-MM-dd)"), + parameterWithName("cursor").optional().description("커서(마지막 focus ID). 최초 조회 시 미전달"), + parameterWithName("size").description("조회할 개수") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.items").type(ARRAY).description("포커스 도서 목록"), + fieldWithPath("result.items[].bookId").type(NUMBER).description("도서 ID"), + fieldWithPath("result.items[].title").type(STRING).description("도서 제목"), + fieldWithPath("result.items[].author").type(STRING).description("도서 저자"), + fieldWithPath("result.items[].focusSec").type(NUMBER).description("해당 포커스 시간(초)"), + fieldWithPath("result.items[].coverUrl").type(STRING).description("도서 커버 URL"), + fieldWithPath("result.nextCursor").type(NUMBER).description("다음 커서"), + fieldWithPath("result.hasNext").type(BOOLEAN).description("다음 페이지 존재 여부") + )) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("date 형식이 잘못되면 400") + @WithCustomUser + void invalidDateFormat() throws Exception { + mockMvc.perform( + get("/api/library/focus-records") + .param("date", "2026/02/26") + .header(AUTH_HEADER, AUTH_TOKEN) + ) + .andExpect(status().isBadRequest()); + } + } + } } diff --git a/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java index af38191..96417f2 100644 --- a/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java +++ b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java @@ -1,15 +1,22 @@ package app.nook.controller.library; import app.nook.focus.repository.FocusRepository; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; +import app.nook.global.common.security.WithCustomUser; +import app.nook.global.config.WebSecurityConfig; import app.nook.global.docs.ApiResponseSnippet; +import app.nook.library.controller.LibraryStatsController; import app.nook.library.dto.FocusRankDto; import app.nook.library.dto.FocusTimeSlot; import app.nook.library.service.LibraryStatsService; -import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; -import app.nook.user.service.CustomUserDetails; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -24,10 +31,20 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class LibraryStatsControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = LibraryStatsController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +class LibraryStatsControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private LibraryStatsService libraryStatsService; @@ -35,113 +52,143 @@ class LibraryStatsControllerTest extends AbstractRestDocsTests { @MockitoBean private FocusRepository focusRepository; - @Test - void 월별_포커스_통계_조회_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - - FocusRankDto.MonthlyBooksResponseDto response = - new FocusRankDto.MonthlyBooksResponseDto( - YearMonth.of(2026, 2), - 60, - List.of( - new FocusRankDto.DailyBookItem( - LocalDate.of(2026, 2, 1), - 2L, - new FocusRankDto.BookCalendarInfo(101L, "https://example.com/101.jpg") + @DisplayName("월별 포커스 통계 조회") + @Nested + class ViewMonthlyStats { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("월별 포커스 통계 조회") + @WithCustomUser + void viewMonthlyStats() throws Exception { + FocusRankDto.MonthlyBooksResponseDto response = + new FocusRankDto.MonthlyBooksResponseDto( + YearMonth.of(2026, 2), + 2, + List.of( + new FocusRankDto.DailyBookItem( + LocalDate.of(2026, 2, 1), + 2L, + new FocusRankDto.BookCalendarInfo(101L, "https://example.com/101.jpg") + ) ) + ); + + given(libraryStatsService.viewMonthly(any(), any(YearMonth.class))) + .willReturn(response); + + mockMvc.perform( + get("/api/library/stats/monthly") + .param("yearMonth", "2026-02") + .header(AUTH_HEADER, AUTH_TOKEN) ) - ); - - given(libraryStatsService.viewMonthly(any(), any(YearMonth.class))) - .willReturn(response); - - // when & then - mockMvc.perform( - get("/api/library/stats/monthly") - .param("yearMonth", "2026-02") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isOk()) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - queryParameters( - parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") - ), - responseFields(ApiResponseSnippet.withResult( - fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), - fieldWithPath("result.totalFocusMin").type(JsonFieldType.NUMBER).description("월 전체 포커스 시간(분)"), - fieldWithPath("result.days").type(JsonFieldType.ARRAY).description("일자별 통계 목록"), - fieldWithPath("result.days[].date").type(JsonFieldType.STRING).description("날짜"), - fieldWithPath("result.days[].bookCount").type(JsonFieldType.NUMBER).description("해당 날짜에 읽은 책 수"), - fieldWithPath("result.days[].topBook").type(JsonFieldType.OBJECT).optional().description("가장 오래 읽은 책"), - fieldWithPath("result.days[].topBook.bookId").type(JsonFieldType.NUMBER).optional().description("도서 ID"), - fieldWithPath("result.days[].topBook.coverUrl").type(JsonFieldType.STRING).optional().description("도서 커버 URL") - )) - )); + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + queryParameters( + parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), + fieldWithPath("result.totalBookCount").type(JsonFieldType.NUMBER).description("월 전체 책 수"), + fieldWithPath("result.days").type(JsonFieldType.ARRAY).description("일자별 통계 목록"), + fieldWithPath("result.days[].date").type(JsonFieldType.STRING).description("날짜"), + fieldWithPath("result.days[].bookCount").type(JsonFieldType.NUMBER).description("해당 날짜에 읽은 책 수"), + fieldWithPath("result.days[].topBook").type(JsonFieldType.OBJECT).optional().description("가장 오래 읽은 책"), + fieldWithPath("result.days[].topBook.bookId").type(JsonFieldType.NUMBER).optional().description("도서 ID"), + fieldWithPath("result.days[].topBook.coverUrl").type(JsonFieldType.STRING).optional().description("도서 커버 URL") + )) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("인증 정보가 없으면 401") + void viewMonthlyStatsWithoutAuth() throws Exception { + mockMvc.perform( + get("/api/library/stats/monthly") + .param("yearMonth", "2026-02") + ) + .andExpect(status().isUnauthorized()); + } + } } - @Test - void 월별_포커스_시간_통계_조회_성공() throws Exception { - // given - User user = User.builder() - .email("jiwon@kakao.com") - .nickName("jiwon") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - - CustomUserDetails userDetails = new CustomUserDetails(user); - - FocusRankDto.FocusBookResponseDto response = - new FocusRankDto.FocusBookResponseDto( - YearMonth.of(2026, 2), - 95, - List.of( - new FocusRankDto.FocusDateItem( - LocalDate.of(2026, 2, 1), - FocusTimeSlot.FOCUS_04 - ), - new FocusRankDto.FocusDateItem( - LocalDate.of(2026, 2, 2), - FocusTimeSlot.FOCUS_02 + @DisplayName("월별 포커스 시간 통계 조회") + @Nested + class ViewFocusMonthlyStats { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("월별 포커스 시간 통계 조회") + @WithCustomUser + void viewFocusMonthlyStats() throws Exception { + FocusRankDto.FocusBookResponseDto response = + new FocusRankDto.FocusBookResponseDto( + YearMonth.of(2026, 2), + 95, + List.of( + new FocusRankDto.FocusDateItem( + LocalDate.of(2026, 2, 1), + FocusTimeSlot.FOCUS_04 + ), + new FocusRankDto.FocusDateItem( + LocalDate.of(2026, 2, 2), + FocusTimeSlot.FOCUS_02 + ) ) + ); + + given(libraryStatsService.viewFocusTimeStats(any(), any(YearMonth.class))) + .willReturn(response); + + mockMvc.perform( + get("/api/library/stats/focus-monthly") + .param("yearMonth", "2026-02") + .header(AUTH_HEADER, AUTH_TOKEN) + ) + .andExpect(status().isOk()) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + queryParameters( + parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") + ), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), + fieldWithPath("result.totalFocusMin").type(JsonFieldType.NUMBER).description("월 전체 포커스 시간(분)"), + fieldWithPath("result.focusBookItems").type(JsonFieldType.ARRAY).description("일자별 포커스 강도 목록"), + fieldWithPath("result.focusBookItems[].date").type(JsonFieldType.STRING).description("날짜"), + fieldWithPath("result.focusBookItems[].timeSlot").type(JsonFieldType.STRING).description("포커스 강도 슬롯 (FOCUS_00~FOCUS_04)") + )) + )); + } + } + + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("yearMonth 형식이 잘못되면 400") + @WithCustomUser + void invalidYearMonthFormat() throws Exception { + mockMvc.perform( + get("/api/library/stats/focus-monthly") + .param("yearMonth", "2026/02") + .header(AUTH_HEADER, AUTH_TOKEN) ) - ); - - given(libraryStatsService.viewFocusTimeStats(any(), any(YearMonth.class))) - .willReturn(response); - - // when & then - mockMvc.perform( - get("/api/library/stats/focus-monthly") - .param("yearMonth", "2026-02") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isOk()) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - queryParameters( - parameterWithName("yearMonth").description("조회 대상 월 (yyyy-MM)") - ), - responseFields(ApiResponseSnippet.withResult( - fieldWithPath("result.yearMonth").type(JsonFieldType.STRING).description("조회 월"), - fieldWithPath("result.totalFocusMin").type(JsonFieldType.NUMBER).description("월 전체 포커스 시간(분)"), - fieldWithPath("result.focusBookItems").type(JsonFieldType.ARRAY).description("일자별 포커스 강도 목록"), - fieldWithPath("result.focusBookItems[].date").type(JsonFieldType.STRING).description("날짜"), - fieldWithPath("result.focusBookItems[].timeSlot").type(JsonFieldType.STRING).description("포커스 강도 슬롯 (FOCUS_00~FOCUS_04)") - )) - )); + .andExpect(status().isBadRequest()); + } + } } } diff --git a/src/test/java/app/nook/controller/user/AuthControllerTest.java b/src/test/java/app/nook/controller/user/AuthControllerTest.java index 8f5a597..74af949 100644 --- a/src/test/java/app/nook/controller/user/AuthControllerTest.java +++ b/src/test/java/app/nook/controller/user/AuthControllerTest.java @@ -1,19 +1,26 @@ package app.nook.controller.user; -import app.nook.global.common.AbstractRestDocsTests; +import app.nook.global.common.AbstractWebMvcRestDocsTests; import app.nook.global.docs.ApiResponseSnippet; import app.nook.global.exception.CustomException; import app.nook.global.response.ErrorCode; +import app.nook.global.config.WebSecurityConfig; +import app.nook.user.controller.AuthController; import app.nook.user.dto.OAuthDTO; import app.nook.user.dto.UserDTO; import app.nook.user.domain.User; import app.nook.user.domain.enums.UserRole; import app.nook.user.oauth.OAuthService; +import app.nook.user.filter.JwtExceptionFilter; +import app.nook.user.filter.JwtFilter; import app.nook.user.service.CustomUserDetails; import app.nook.user.service.UserService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -27,7 +34,18 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class AuthControllerTest extends AbstractRestDocsTests { +@WebMvcTest( + controllers = AuthController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + WebSecurityConfig.class, + JwtFilter.class, + JwtExceptionFilter.class + } + ) +) +class AuthControllerTest extends AbstractWebMvcRestDocsTests { @MockitoBean private OAuthService oAuthService; diff --git a/src/test/java/app/nook/global/common/AbstractRestDocsTests.java b/src/test/java/app/nook/global/common/AbstractRestDocsTests.java index d750758..a102f87 100644 --- a/src/test/java/app/nook/global/common/AbstractRestDocsTests.java +++ b/src/test/java/app/nook/global/common/AbstractRestDocsTests.java @@ -11,7 +11,6 @@ import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.snippet.Snippet; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultHandler; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; @@ -27,6 +26,9 @@ import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +/** + * 통합 테스트용 + */ @Import(RestDocsConfiguration.class) @ExtendWith(RestDocumentationExtension.class) @AutoConfigureRestDocs @@ -34,17 +36,16 @@ "spring.profiles.active=test" }) @ActiveProfiles("test") -@TestPropertySource(properties = { - "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", - "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect" -}) public abstract class AbstractRestDocsTests { + protected static final String AUTH_HEADER = "Authorization"; + protected static final String AUTH_TOKEN = "Bearer test-access-token"; + @Autowired protected RestDocumentationResultHandler restDocs; - @Autowired - protected Snippet authorizationHeaderSnippet; + @Autowired + protected Snippet authorizationHeaderSnippet; protected MockMvc mockMvc; @@ -79,5 +80,4 @@ protected ResultHandler documentWithAuth(String identifier, Snippet... snippets) preprocessResponse(prettyPrint()), mergedSnippets); } - } diff --git a/src/test/java/app/nook/global/common/AbstractWebMvcRestDocsTests.java b/src/test/java/app/nook/global/common/AbstractWebMvcRestDocsTests.java new file mode 100644 index 0000000..eae46a3 --- /dev/null +++ b/src/test/java/app/nook/global/common/AbstractWebMvcRestDocsTests.java @@ -0,0 +1,80 @@ +package app.nook.global.common; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.snippet.Snippet; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultHandler; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import java.util.Arrays; +import java.util.stream.Stream; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +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.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * @WebMvcTest 기반 테스트용 + */ +@Import({RestDocsConfiguration.class, TestSecurityConfig.class}) +@ExtendWith(RestDocumentationExtension.class) +@AutoConfigureRestDocs +@ActiveProfiles("test") +public abstract class AbstractWebMvcRestDocsTests { + + protected static final String AUTH_HEADER = "Authorization"; + protected static final String AUTH_TOKEN = "Bearer test-access-token"; + + @Autowired + protected RestDocumentationResultHandler restDocs; + + @Autowired + protected Snippet authorizationHeaderSnippet; + + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + protected MockMvc mockMvc; + + @BeforeEach + void setUp( + final WebApplicationContext context, + final RestDocumentationContextProvider restDocumentation) { + + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(MockMvcResultHandlers.print()) + .alwaysDo(restDocs) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .build(); + } + + protected ResultHandler documentWithAuth(String identifier, Snippet... snippets) { + Snippet[] mergedSnippets = Stream.concat( + Stream.of(authorizationHeaderSnippet), + Arrays.stream(snippets) + ).toArray(Snippet[]::new); + + return document(identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + mergedSnippets); + } +} diff --git a/src/test/java/app/nook/global/common/TestSecurityConfig.java b/src/test/java/app/nook/global/common/TestSecurityConfig.java new file mode 100644 index 0000000..ae38355 --- /dev/null +++ b/src/test/java/app/nook/global/common/TestSecurityConfig.java @@ -0,0 +1,32 @@ +package app.nook.global.common; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> + response.sendError(HttpStatus.UNAUTHORIZED.value())) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll() + ); + + return http.build(); + } +} diff --git a/src/test/java/app/nook/global/common/security/WithCustomUser.java b/src/test/java/app/nook/global/common/security/WithCustomUser.java new file mode 100644 index 0000000..1bce8d6 --- /dev/null +++ b/src/test/java/app/nook/global/common/security/WithCustomUser.java @@ -0,0 +1,20 @@ +package app.nook.global.common.security; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@WithSecurityContext(factory = WithCustomUserSecurityContextFactory.class) +public @interface WithCustomUser { + long userId() default 1L; + String email() default "jiwon@kakao.com"; + String nickName() default "jiwon"; + String role() default "USER"; +} diff --git a/src/test/java/app/nook/global/common/security/WithCustomUserSecurityContextFactory.java b/src/test/java/app/nook/global/common/security/WithCustomUserSecurityContextFactory.java new file mode 100644 index 0000000..1882026 --- /dev/null +++ b/src/test/java/app/nook/global/common/security/WithCustomUserSecurityContextFactory.java @@ -0,0 +1,37 @@ +package app.nook.global.common.security; + +import app.nook.user.domain.User; +import app.nook.user.domain.enums.UserRole; +import app.nook.user.service.CustomUserDetails; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.test.util.ReflectionTestUtils; + +public class WithCustomUserSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithCustomUser annotation) { + User user = User.builder() + .email(annotation.email()) + .nickName(annotation.nickName()) + .provider("kakao") + .providerId("provider-id") + .role(UserRole.valueOf(annotation.role())) + .build(); + ReflectionTestUtils.setField(user, "id", annotation.userId()); + + CustomUserDetails principal = new CustomUserDetails(user); + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + principal, + null, + principal.getAuthorities() + ); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + return context; + } +} diff --git a/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java index a0d54a1..660e2d0 100644 --- a/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java +++ b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java @@ -2,6 +2,8 @@ import app.nook.focus.repository.FocusRepository; import app.nook.library.dto.FocusRankDto; +import app.nook.library.dto.FocusTimeSlot; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,144 +29,212 @@ class LibraryStatsServiceTest { @InjectMocks private LibraryStatsService libraryStatsService; + private FocusRankDto.MonthlyFocusRow row( + LocalDate date, + Long bookId, + String coverImageUrl, + Long totalSec + ) { + return new FocusRankDto.MonthlyFocusRow(date, bookId, coverImageUrl, totalSec); + } + + private FocusRepository.MonthlyFocusStatsProjection toProjection(FocusRankDto.MonthlyFocusRow row) { + return new FocusRepository.MonthlyFocusStatsProjection() { + @Override + public Integer getYearValue() { + return row.date().getYear(); + } + + @Override + public Integer getMonthValue() { + return row.date().getMonthValue(); + } + + @Override + public Integer getDayValue() { + return row.date().getDayOfMonth(); + } + + @Override + public Long getBookId() { + return row.bookId(); + } + + @Override + public String getCoverImageUrl() { + return row.coverImageUrl(); + } + + @Override + public Long getTotalSec() { + return row.totalSec(); + } + }; + } + + private FocusRepository.FocusTimeStatsProjection timeProjection(LocalDate date, Long totalSec) { + return new FocusRepository.FocusTimeStatsProjection() { + @Override + public Integer getYearValue() { + return date.getYear(); + } + + @Override + public Integer getMonthValue() { + return date.getMonthValue(); + } + + @Override + public Integer getDayValue() { + return date.getDayOfMonth(); + } + + @Override + public Long getTotalSec() { + return totalSec; + } + }; + } + + @DisplayName("월별 포커스 통계 조회") @Nested class ViewMonthly { - @Test - void 정상_케이스() { - // given - Long userId = 1L; - YearMonth yearMonth = YearMonth.of(2026, 2); - LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - - List rows = List.of( - row(LocalDate.of(2026, 2, 1), 11L, "cover-11", 1200L), - row(LocalDate.of(2026, 2, 2), 22L, "cover-22", 600L), - row(LocalDate.of(2026, 2, 3), 33L, "cover-33", 1800L) - ); - given(focusRepository.findMonthlyFocusStats(userId, start, end)) - .willReturn(rows.stream().map(this::toProjection).toList()); - - // when - FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); - - // then - verify(focusRepository).findMonthlyFocusStats(userId, start, end); - assertThat(result.yearMonth()).isEqualTo(yearMonth); - assertThat(result.totalFocusMin()).isEqualTo((1200 + 600 + 1800) / 60); - assertThat(result.days()).hasSize(3); - - FocusRankDto.DailyBookItem day1 = result.days().get(0); - assertThat(day1.date()).isEqualTo(LocalDate.of(2026, 2, 1)); - assertThat(day1.bookCount()).isEqualTo(1L); - assertThat(day1.topBook().bookId()).isEqualTo(11L); - assertThat(day1.topBook().coverUrl()).isEqualTo("cover-11"); - - FocusRankDto.DailyBookItem day2 = result.days().get(1); - assertThat(day2.date()).isEqualTo(LocalDate.of(2026, 2, 2)); - assertThat(day2.bookCount()).isEqualTo(1L); - assertThat(day2.topBook().bookId()).isEqualTo(22L); - - FocusRankDto.DailyBookItem day3 = result.days().get(2); - assertThat(day3.date()).isEqualTo(LocalDate.of(2026, 2, 3)); - assertThat(day3.bookCount()).isEqualTo(1L); - assertThat(day3.topBook().bookId()).isEqualTo(33L); + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("월별 통계를 정상 조회한다") + void 정상_케이스() { + Long userId = 1L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + List rows = List.of( + row(LocalDate.of(2026, 2, 1), 11L, "cover-11", 1200L), + row(LocalDate.of(2026, 2, 2), 22L, "cover-22", 600L), + row(LocalDate.of(2026, 2, 3), 33L, "cover-33", 1800L) + ); + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(rows.stream().map(LibraryStatsServiceTest.this::toProjection).toList()); + + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + assertThat(result.yearMonth()).isEqualTo(yearMonth); + assertThat(result.totalBookCount()).isEqualTo(3); + assertThat(result.days()).hasSize(3); + } + + @Test + @DisplayName("동일 날짜 다중 도서일 때 topBook을 계산한다") + void 동일_날짜에_여러_책_존재시_topBook_검증() { + Long userId = 2L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + List rows = List.of( + row(LocalDate.of(2026, 2, 10), 100L, "cover-100", 300L), + row(LocalDate.of(2026, 2, 10), 200L, "cover-200", 1200L), + row(LocalDate.of(2026, 2, 11), 300L, "cover-300", 600L) + ); + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(rows.stream().map(LibraryStatsServiceTest.this::toProjection).toList()); + + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + FocusRankDto.DailyBookItem sameDay = result.days().stream() + .filter(d -> d.date().equals(LocalDate.of(2026, 2, 10))) + .findFirst() + .orElseThrow(); + assertThat(sameDay.bookCount()).isEqualTo(2L); + assertThat(sameDay.topBook().bookId()).isEqualTo(200L); + } } - @Test - void 동일_날짜에_여러_책_존재시_topBook_검증() { - // given - Long userId = 2L; - YearMonth yearMonth = YearMonth.of(2026, 2); - LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - - List rows = List.of( - row(LocalDate.of(2026, 2, 10), 100L, "cover-100", 300L), - row(LocalDate.of(2026, 2, 10), 200L, "cover-200", 1200L), - row(LocalDate.of(2026, 2, 11), 300L, "cover-300", 600L) - ); - given(focusRepository.findMonthlyFocusStats(userId, start, end)) - .willReturn(rows.stream().map(this::toProjection).toList()); - - // when - FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); - - // then - verify(focusRepository).findMonthlyFocusStats(userId, start, end); - FocusRankDto.DailyBookItem sameDay = result.days().stream() - .filter(d -> d.date().equals(LocalDate.of(2026, 2, 10))) - .findFirst() - .orElseThrow(); - - assertThat(sameDay.bookCount()).isEqualTo(2L); - assertThat(sameDay.topBook().bookId()).isEqualTo(200L); - assertThat(sameDay.topBook().coverUrl()).isEqualTo("cover-200"); - } + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("데이터가 없으면 빈 응답을 반환한다") + void 포커스_기록이_없으면_빈_응답() { + Long userId = 3L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - @Test - void 포커스_기록이_없으면_빈_응답() { - // given - Long userId = 3L; - YearMonth yearMonth = YearMonth.of(2026, 2); - LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - - given(focusRepository.findMonthlyFocusStats(userId, start, end)) - .willReturn(List.of()); - - // when - FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); - - // then - verify(focusRepository).findMonthlyFocusStats(userId, start, end); - assertThat(result.yearMonth()).isEqualTo(yearMonth); - assertThat(result.totalFocusMin()).isZero(); - assertThat(result.days()).isEmpty(); + given(focusRepository.findMonthlyFocusStats(userId, start, end)) + .willReturn(List.of()); + + FocusRankDto.MonthlyBooksResponseDto result = libraryStatsService.viewMonthly(userId, yearMonth); + + verify(focusRepository).findMonthlyFocusStats(userId, start, end); + assertThat(result.yearMonth()).isEqualTo(yearMonth); + assertThat(result.totalBookCount()).isZero(); + assertThat(result.days()).isEmpty(); + } } + } - private FocusRankDto.MonthlyFocusRow row( - LocalDate date, - Long bookId, - String coverImageUrl, - Long totalSec - ) { - return new FocusRankDto.MonthlyFocusRow(date, bookId, coverImageUrl, totalSec); + @DisplayName("월별 포커스 시간 통계 조회") + @Nested + class ViewFocusTimeStats { + + @DisplayName("성공") + @Nested + class Success { + + @Test + @DisplayName("월별 포커스 시간 통계를 정상 조회한다") + void 정상_조회() { + Long userId = 10L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + given(focusRepository.findFocusTimeStats(userId, start, end)) + .willReturn(List.of( + timeProjection(LocalDate.of(2026, 2, 1), 3600L), + timeProjection(LocalDate.of(2026, 2, 2), 1800L) + )); + + FocusRankDto.FocusBookResponseDto result = libraryStatsService.viewFocusTimeStats(userId, yearMonth); + + verify(focusRepository).findFocusTimeStats(userId, start, end); + assertThat(result.yearMonth()).isEqualTo(yearMonth); + assertThat(result.totalFocusMin()).isEqualTo(90); + assertThat(result.focusBookItems()).hasSize(2); + assertThat(result.focusBookItems().get(0).timeSlot()).isEqualTo(FocusTimeSlot.FOCUS_04); + assertThat(result.focusBookItems().get(1).timeSlot()).isEqualTo(FocusTimeSlot.FOCUS_02); + } } - private FocusRepository.MonthlyFocusStatsProjection toProjection(FocusRankDto.MonthlyFocusRow row) { - return new FocusRepository.MonthlyFocusStatsProjection() { - @Override - public Integer getYearValue() { - return row.date().getYear(); - } - - @Override - public Integer getMonthValue() { - return row.date().getMonthValue(); - } - - @Override - public Integer getDayValue() { - return row.date().getDayOfMonth(); - } - - @Override - public Long getBookId() { - return row.bookId(); - } - - @Override - public String getCoverImageUrl() { - return row.coverImageUrl(); - } - - @Override - public Long getTotalSec() { - return row.totalSec(); - } - }; + @DisplayName("실패") + @Nested + class Failure { + + @Test + @DisplayName("데이터가 없으면 빈 응답을 반환한다") + void 데이터_없음() { + Long userId = 11L; + YearMonth yearMonth = YearMonth.of(2026, 2); + LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + given(focusRepository.findFocusTimeStats(userId, start, end)) + .willReturn(List.of()); + + FocusRankDto.FocusBookResponseDto result = libraryStatsService.viewFocusTimeStats(userId, yearMonth); + + verify(focusRepository).findFocusTimeStats(userId, start, end); + assertThat(result.totalFocusMin()).isZero(); + assertThat(result.focusBookItems()).isEmpty(); + } } } } From a4aefd12d32a90e227fd9d839aa20c583b220748 Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:14:34 +0900 Subject: [PATCH 13/14] =?UTF-8?q?[FEAT]=20=EC=84=9C=EC=9E=AC=20cascade=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generated/app/nook/library/domain/QLibrary.java | 4 ++++ src/main/java/app/nook/library/domain/Library.java | 10 ++++++++++ .../java/app/nook/library/service/LibraryService.java | 1 - 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/generated/app/nook/library/domain/QLibrary.java b/src/main/generated/app/nook/library/domain/QLibrary.java index 6bf643b..cde2bf3 100644 --- a/src/main/generated/app/nook/library/domain/QLibrary.java +++ b/src/main/generated/app/nook/library/domain/QLibrary.java @@ -26,11 +26,15 @@ public class QLibrary extends EntityPathBase { public final app.nook.book.domain.QBook book; + public final ListPath bookTimeLines = this.createList("bookTimeLines", app.nook.timeline.domain.BookTimeLine.class, app.nook.timeline.domain.QBookTimeLine.class, PathInits.DIRECT2); + //inherited public final DateTimePath createdDate = _super.createdDate; public final DatePath endedAt = createDate("endedAt", java.time.LocalDate.class); + public final ListPath focuses = this.createList("focuses", app.nook.focus.domain.Focus.class, app.nook.focus.domain.QFocus.class, PathInits.DIRECT2); + public final NumberPath focusSec = createNumber("focusSec", Long.class); public final NumberPath id = createNumber("id", Long.class); diff --git a/src/main/java/app/nook/library/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index 44bd5e5..3a9b2ee 100644 --- a/src/main/java/app/nook/library/domain/Library.java +++ b/src/main/java/app/nook/library/domain/Library.java @@ -1,8 +1,10 @@ package app.nook.library.domain; import app.nook.book.domain.Book; +import app.nook.focus.domain.Focus; import app.nook.global.common.BaseEntity; import app.nook.library.domain.enums.ReadingStatus; +import app.nook.timeline.domain.BookTimeLine; import app.nook.user.domain.User; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -10,6 +12,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; import java.time.LocalDate; import java.time.LocalDateTime; @@ -51,6 +55,12 @@ public class Library extends BaseEntity { @JoinColumn(name = "book_id", nullable = false) private Book book; + @OneToMany(mappedBy = "library", cascade = CascadeType.ALL, orphanRemoval = true) + private List focuses = new ArrayList<>(); + + @OneToMany(mappedBy = "library", cascade = CascadeType.ALL, orphanRemoval = true) + private List bookTimeLines = new ArrayList<>(); + @Column(name = "started_at") private LocalDate startedAt; diff --git a/src/main/java/app/nook/library/service/LibraryService.java b/src/main/java/app/nook/library/service/LibraryService.java index 065591e..32f3ac6 100644 --- a/src/main/java/app/nook/library/service/LibraryService.java +++ b/src/main/java/app/nook/library/service/LibraryService.java @@ -100,7 +100,6 @@ public void deleteById(User user, Long bookId){ .collect(Collectors.toList()); libraryRepository.delete(library); - bookTimeLineRepository.deleteByLibrary(library); eventPublisher.publishEvent( LibraryCacheInvalidateEvent.statusAndMonthly(user.getId(), affectedYearMonths) From bee9625a1bc36f1653bdce622dbf3cfbccf0011e Mon Sep 17 00:00:00 2001 From: ljw42b <80021912+JiwonLee42@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:18:42 +0900 Subject: [PATCH 14/14] =?UTF-8?q?[CHORE]=20WithCustomUser=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LibraryCachingIntegrationTest.java | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java index 382892c..cea7ec5 100644 --- a/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java +++ b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java @@ -4,13 +4,15 @@ import app.nook.book.domain.Book; import app.nook.book.repository.BookRepository; import app.nook.focus.repository.FocusRepository; +import app.nook.global.common.security.WithCustomUser; import app.nook.library.domain.Library; import app.nook.library.repository.LibraryRepository; import app.nook.timeline.repository.BookTimeLineRepository; import app.nook.user.domain.User; -import app.nook.user.domain.enums.UserRole; +import app.nook.user.service.CustomUserDetails; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; @@ -40,6 +42,7 @@ properties = "spring.main.allow-bean-definition-overriding=true" ) @ActiveProfiles("test") +@WithCustomUser(userId = 1L) class LibraryCachingIntegrationTest { @TestConfiguration @@ -86,7 +89,7 @@ void clearCaches() { @Test void viewMonthly_동일키_호출시_한번만_조회된다() { // given - Long userId = 1L; + Long userId = currentUserId(); YearMonth yearMonth = YearMonth.of(2026, 2); LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); @@ -105,20 +108,13 @@ void clearCaches() { @Test void deleteById_후_viewMonthly_재조회시_캐시가_무효화된다() { // given - Long userId = 1L; + Long userId = currentUserId(); Long bookId = 100L; YearMonth yearMonth = YearMonth.of(2026, 2); LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); LocalDateTime end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); - User user = User.builder() - .email("user@test.com") - .nickName("user") - .provider("kakao") - .providerId("provider-id") - .role(UserRole.USER) - .build(); - ReflectionTestUtils.setField(user, "id", userId); + User user = currentUser(); Book book = Book.builder() .isbn13("9780000000001") @@ -149,7 +145,7 @@ void clearCaches() { @Test void viewMonthly_키가_다르면_각각_캐시가_분리된다() { // given - Long user1 = 1L; + Long user1 = currentUserId(); Long user2 = 2L; YearMonth feb = YearMonth.of(2026, 2); YearMonth mar = YearMonth.of(2026, 3); @@ -181,6 +177,17 @@ private void clearCache(String cacheName) { } } + private Long currentUserId() { + return currentUser().getId(); + } + + private User currentUser() { + CustomUserDetails principal = (CustomUserDetails) SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + return principal.getUser(); + } + private FocusRepository.MonthlyFocusStatsProjection monthlyProjection( LocalDate dateValue, Long bookId,