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 diff --git a/build.gradle b/build.gradle index e43fe0b..a4e44d9 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,7 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-cache' // lombok compileOnly 'org.projectlombok:lombok' @@ -169,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 { 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/main/generated/app/nook/library/domain/QLibrary.java b/src/main/generated/app/nook/library/domain/QLibrary.java index c4afdda..cde2bf3 100644 --- a/src/main/generated/app/nook/library/domain/QLibrary.java +++ b/src/main/generated/app/nook/library/domain/QLibrary.java @@ -26,18 +26,24 @@ 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 NumberPath focusMin = createNumber("focusMin", Long.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); //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/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/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/focus/repository/FocusRepository.java b/src/main/java/app/nook/focus/repository/FocusRepository.java new file mode 100644 index 0000000..1650258 --- /dev/null +++ b/src/main/java/app/nook/focus/repository/FocusRepository.java @@ -0,0 +1,138 @@ +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; + +import java.time.LocalDateTime; +import java.util.List; + +public interface FocusRepository extends JpaRepository { + + interface FocusYearMonthProjection { + Integer getYearValue(); + Integer getMonthValue(); + } + + interface MonthlyFocusStatsProjection { + Integer getYearValue(); + Integer getMonthValue(); + Integer getDayValue(); + Long getBookId(); + String getCoverImageUrl(); + Long getTotalSec(); + } + + interface FocusTimeStatsProjection { + Integer getYearValue(); + Integer getMonthValue(); + Integer getDayValue(); + Long getTotalSec(); + } + + interface FocusRangeProjection { + LocalDateTime getStartedAt(); + LocalDateTime getEndedAt(); + } + + @Query(""" + select + 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 + from Focus f + join f.library l + where l.user.id = :userId + and f.startedAt >= :start + and f.startedAt < :end + 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( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + + @Query(""" + select distinct + 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 + """) + List findDistinctFocusYearMonthsByLibraryAndUser( + @Param("libraryId") Long libraryId, + @Param("userId") Long userId + ); + + + // 포커스 통계 조회 + @Query(""" + select + 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 + EXTRACT(YEAR FROM f.startedAt), + EXTRACT(MONTH FROM f.startedAt), + EXTRACT(DAY FROM f.startedAt) + """) + List findFocusTimeStats( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @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 + ); + + @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/global/config/CacheConfig.java b/src/main/java/app/nook/global/config/CacheConfig.java new file mode 100644 index 0000000..3c8ba2a --- /dev/null +++ b/src/main/java/app/nook/global/config/CacheConfig.java @@ -0,0 +1,49 @@ +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 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; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + + RedisCacheConfiguration defaultConfig = + RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + 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(); + } +} 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/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/domain/Library.java b/src/main/java/app/nook/library/domain/Library.java index 0bcb440..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; @@ -27,20 +31,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) @@ -55,14 +55,23 @@ 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; @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( @@ -71,10 +80,30 @@ public Library( ){ this.user = user; this.book = book; + this.startedAt = LocalDateTime.now().toLocalDate(); } - // 포커스를 시작했는데 독서 전이면 독서 중으로 변경 + + // 포커스 시 읽은 기록 업데이트 + public void recordFocus(long addedSeconds) { + this.focusSec += addedSeconds; + } + + // 페이지 업데이트 + public void recordPage(int page) { + this.page = page; + } + + // 완독 -> 독서 중 + // 독서 중 -> 완독 : endTime 업데이트 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 new file mode 100644 index 0000000..35797b2 --- /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 totalBookCount, + 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 focusSec + ) {} + + // 중간 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..e0febe9 100644 --- a/src/main/java/app/nook/library/dto/LibraryViewDto.java +++ b/src/main/java/app/nook/library/dto/LibraryViewDto.java @@ -27,30 +27,13 @@ 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 FocusBookResponseDto( - YearMonth yearMonth, - int totalFocusMin, - List focusBookItems - ){} - - public record FocusBookItem( - LocalDate date, - FocusTimeSlot timeSlot - ){} - public record StatusBookResponseDto( ReadingStatus readingStatus, int totalBookNum, @@ -91,4 +74,7 @@ public record FinishedBookItem( LocalDate endedAt ) implements UserStatusBookItem {} + public record BookCountResponseDto( + int totalBookNum + ){} } 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 beaeced..48ea867 100644 --- a/src/main/java/app/nook/library/repository/LibraryRepository.java +++ b/src/main/java/app/nook/library/repository/LibraryRepository.java @@ -12,10 +12,9 @@ import org.springframework.data.repository.query.Param; 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); @@ -58,4 +57,5 @@ Page searchByUserIdAndKeyword( @Param("keyword") String keyword, Pageable pageable); + int countByUser(User user); } 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..32f3ac6 100644 --- a/src/main/java/app/nook/library/service/LibraryService.java +++ b/src/main/java/app/nook/library/service/LibraryService.java @@ -1,17 +1,18 @@ 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.domain.Focus; +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; @@ -20,6 +21,8 @@ import app.nook.timeline.repository.BookTimeLineRepository; import app.nook.user.domain.User; import lombok.RequiredArgsConstructor; +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; @@ -27,9 +30,12 @@ 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; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -39,8 +45,15 @@ public class LibraryService { private final LibraryRepository libraryRepository; private final BookRepository bookRepository; private final BookTimeLineRepository bookTimeLineRepository; + private final FocusRepository focusRepository; + private final ApplicationEventPublisher eventPublisher; - // TODO: Book 도메인 에러코드로 추후 수정 + + // 서재 책 개수 조회 + // 서재 책은 최대 100000권이므로 int로 반환 타입 설정 + public LibraryViewDto.BookCountResponseDto countBooks(User user) { + return new LibraryViewDto.BookCountResponseDto(libraryRepository.countByUser(user)); + } // 서재 책 등록 @Transactional @@ -52,10 +65,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 +76,34 @@ public void save(User user,Long bookId) { savedLibrary.getId() ); bookTimeLineRepository.save(timeLine); + eventPublisher.publishEvent(LibraryCacheInvalidateEvent.statusOnly(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() + ).stream() + .map(ym -> YearMonth.of(ym.getYearValue(), ym.getMonthValue())) + .collect(Collectors.toList()); + libraryRepository.delete(library); + + eventPublisher.publishEvent( + LibraryCacheInvalidateEvent.statusAndMonthly(user.getId(), affectedYearMonths) + ); } // 서재 책 상태변경 @@ -101,16 +127,15 @@ public void changeStatus(User user, ReadingStatusRequestDto requestDto) { library.getReadingStatus().toString(), library.getId()); bookTimeLineRepository.save(timeLine); + eventPublisher.publishEvent(LibraryCacheInvalidateEvent.statusOnly(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 +180,44 @@ public Page searchBooksInLibrary(Long userId, String keyword, int page, Pageable pageable = PageRequest.of(page, size); 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 new file mode 100644 index 0000000..547250b --- /dev/null +++ b/src/main/java/app/nook/library/service/LibraryStatsService.java @@ -0,0 +1,120 @@ +package app.nook.library.service; + +import app.nook.focus.repository.FocusRepository; +import app.nook.library.dto.FocusRankDto; +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( + LocalDate.of(row.getYearValue(), row.getMonthValue(), row.getDayValue()), + row.getBookId(), + row.getCoverImageUrl(), + row.getTotalSec() + )) + .toList(); + + int totalBookCount = rows.size(); + + // 날짜별로 다시 그룹화 + 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, totalBookCount, 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( + LocalDate.of(row.getYearValue(), row.getMonthValue(), row.getDayValue()), + 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); + + 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..1d806f7 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_date DESC" ), - @Index( - name = "idx_book_timelines_type", - columnList = "type" - ) } ) public class BookTimeLine extends BaseEntity { 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 652d9e1..547c673 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} @@ -21,7 +20,8 @@ spring: show_sql: false dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false - + cache: + type: redis data: redis: host: ${REDIS_HOST:localhost} diff --git a/src/test/java/app/nook/controller/book/BookControllerTest.java b/src/test/java/app/nook/controller/book/BookControllerTest.java index 8c22e93..0f57ddb 100644 --- a/src/test/java/app/nook/controller/book/BookControllerTest.java +++ b/src/test/java/app/nook/controller/book/BookControllerTest.java @@ -1,428 +1,352 @@ -package app.nook.controller.book; - -import app.nook.book.domain.enums.MallType; -import app.nook.book.domain.enums.SourceType; -import app.nook.book.dto.BookRequestDto; -import app.nook.book.dto.BookResponseDto; -import app.nook.book.exception.BookErrorCode; -import app.nook.book.facade.UserBookFacade; -import app.nook.book.service.BookService; -import app.nook.global.common.AbstractRestDocsTests; -import app.nook.global.docs.ApiResponseSnippet; -import app.nook.global.exception.CustomException; -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.mock.web.MockMultipartFile; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.mockito.ArgumentMatchers.*; -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.pathParameters; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -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 { - - @MockitoBean - private BookService bookService; - - @MockitoBean - private UserBookFacade userBookFacade; - - 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", 1L); - userDetails = new CustomUserDetails(user); - } - - @Test - @DisplayName("ISBN으로 도서 상세 조회 성공") - void 도서_상세조회_성공() throws Exception { - // given - String isbn13 = "9788936434267"; - - BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() - .bookId(1L) - .isbn13(isbn13) - .title("채식주의자") - .author("한강") - .publisher("창비") - .publicationDate("2007-10-30") - .mallType("도서") - .mallTypeCode(MallType.BOOK) - .category("소설") - .pages(184) - .description("한강의 소설") - .coverImageUrl("http://example.com/cover.jpg") - .aladinLink("http://aladin.com/book") - .sourceType(SourceType.ALADIN) - .bookShelfId(null) - .build(); - - given(bookService.getBookDetailByIsbn(any(User.class), eq(isbn13))) - .willReturn(response); - - // when & then - mockMvc.perform( - get("/api/books/{isbn13}", isbn13) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.title").value("채식주의자")) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - pathParameters( - parameterWithName("isbn13").description("도서 ISBN13 (13자리 숫자)") - ), - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result.bookId").description("도서 ID"), - fieldWithPath("result.isbn13").description("ISBN13"), - fieldWithPath("result.title").description("도서 제목"), - fieldWithPath("result.author").description("저자"), - fieldWithPath("result.publisher").description("출판사"), - fieldWithPath("result.publicationDate").description("출판일"), - fieldWithPath("result.mallType").description("상품 유형"), - fieldWithPath("result.mallTypeCode").description("상품 유형 코드"), - fieldWithPath("result.category").description("카테고리"), - fieldWithPath("result.pages").description("페이지 수"), - fieldWithPath("result.description").description("도서 설명"), - fieldWithPath("result.coverImageUrl").description("표지 이미지 URL"), - fieldWithPath("result.aladinLink").description("알라딘 링크"), - fieldWithPath("result.sourceType").description("데이터 출처"), - fieldWithPath("result.bookShelfId").description("서재 ID (없으면 null)").optional() - ) - ) - )); - } - - @Test - @DisplayName("잘못된 ISBN 형식으로 조회 시 400 Bad Request") - void 도서_상세조회_실패_잘못된_ISBN_형식() throws Exception { - // given - String invalidIsbn = "123"; // 13자리가 아님 - - // when & then - mockMvc.perform( - get("/api/books/{isbn13}", invalidIsbn) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("존재하지 않는 도서 조회 시 404 Not Found") - void 도서_상세조회_실패_존재하지않는_도서() throws Exception { - // given - String isbn13 = "9999999999999"; - - given(bookService.getBookDetailByIsbn(any(User.class), 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)) - ) - .andExpect(status().isNotFound()); - } - - @Test - @DisplayName("bookId로 도서 상세 조회 성공") - void 도서_상세조회_bookId_성공() throws Exception { - BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() - .bookId(1L) - .isbn13("9788936434267") - .title("테스트책") - .author("저자") - .publisher("출판사") - .publicationDate("2023-03-21") - .mallType("국내도서") - .mallTypeCode(MallType.BOOK) - .category("소설/시/희곡") - .pages(312) - .description("한강의 소설") - .coverImageUrl("http://example.com/cover.jpg") - .sourceType(SourceType.USER) - .bookShelfId(3L) - .build(); - - given(bookService.getBookDetailById(any(User.class), eq(1L))).willReturn(response); - - mockMvc.perform(get("/api/books/id/{bookId}", 1L) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.bookId").value(1L)) - .andExpect(jsonPath("$.result.title").value("테스트책")) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - pathParameters( - parameterWithName("bookId").description("도서 ID") - ), - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result.bookId").description("도서 ID"), - fieldWithPath("result.isbn13").description("ISBN13").optional(), - fieldWithPath("result.title").description("도서 제목"), - fieldWithPath("result.author").description("저자"), - fieldWithPath("result.publisher").description("출판사").optional(), - fieldWithPath("result.publicationDate").description("출판일").optional(), - fieldWithPath("result.mallType").description("상품 유형").optional(), - fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), - fieldWithPath("result.category").description("카테고리").optional(), - fieldWithPath("result.pages").description("페이지 수").optional(), - fieldWithPath("result.description").description("도서 설명").optional(), - fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), - fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), - fieldWithPath("result.sourceType").description("데이터 출처"), - fieldWithPath("result.bookShelfId").description("서재 ID").optional() - ) - ) - )); - } - - @Test - @DisplayName("주간 베스트셀러 목록 조회 성공") - void 주간베스트셀러_조회_성공() throws Exception { - // given - List response = Arrays.asList( - new BookResponseDto.BookPreviewDto( - "9788936434267", - "채식주의자", - "한강", - "http://example.com/cover1.jpg", - "창비", - 1 - ), - new BookResponseDto.BookPreviewDto( - "9788936433598", - "소년이 온다", - "한강", - "http://example.com/cover2.jpg", - "창비", - 2 - ) - ); - - given(bookService.getWeeklyBestsellers()) - .willReturn(response); - - // when & then - mockMvc.perform( - get("/api/books/bestsellers") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result[0].title").value("채식주의자")) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result[].isbn13").description("ISBN13"), - fieldWithPath("result[].title").description("도서 제목"), - fieldWithPath("result[].author").description("저자"), - fieldWithPath("result[].coverImageUrl").description("표지 이미지 URL"), - fieldWithPath("result[].publisher").description("출판사"), - fieldWithPath("result[].rank").description("베스트셀러 순위") - ) - ) - )); - } - - @Test - @DisplayName("사용자 맞춤 추천 베스트셀러 조회 성공") - void 추천베스트셀러_조회_성공() throws Exception { - // given - List response = Collections.singletonList( - new BookResponseDto.BookPreviewDto( - "9788936434267", - "채식주의자", - "한강", - "http://example.com/cover1.jpg", - "창비", - 1 - - ) - ); - - given(bookService.getPersonalizedBestsellers(any(Long.class))) - .willReturn(response); - - // when & then - mockMvc.perform( - get("/api/books/recommendations") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails)) - ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result[0].title").value("채식주의자")) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result[].isbn13").description("ISBN13"), - fieldWithPath("result[].title").description("도서 제목"), - fieldWithPath("result[].author").description("저자"), - fieldWithPath("result[].coverImageUrl").description("표지 이미지 URL"), - fieldWithPath("result[].publisher").description("출판사"), - fieldWithPath("result[].rank").description("베스트셀러 순위") - ) - ) - )); - } - - @Test - @DisplayName("사용자 도서 등록 성공") - void 사용자_도서_등록_성공() throws Exception { - BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() - .bookId(101L) - .title("혼모노") - .author("성해은") - .category("소설/시/희곡") - .sourceType(SourceType.USER) - .bookShelfId(5L) - .build(); - - given(userBookFacade.createUserBook(any(User.class), any(BookRequestDto.CreateUserBookRequest.class))) - .willReturn(response); - - MockMultipartFile cover = new MockMultipartFile( - "coverImage", "cover.png", "image/png", "fake".getBytes() - ); - - mockMvc.perform(multipart("/api/books/user") - .file(cover) - .param("title", "혼모노") - .param("author", "성해은") - .param("categoryName", "소설/시/희곡") - .param("description", "소개") - .param("pages", "348") - .param("publisher", "민음사") - .param("publicationDate", "2023-06-05") - .param("isbn13", "9788936439743") - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS-201")) - .andExpect(jsonPath("$.result.bookId").value(101L)) - .andExpect(jsonPath("$.result.sourceType").value("USER")) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result.bookId").description("도서 ID"), - fieldWithPath("result.isbn13").description("ISBN13").optional(), - fieldWithPath("result.title").description("도서 제목"), - fieldWithPath("result.author").description("저자"), - fieldWithPath("result.publisher").description("출판사").optional(), - fieldWithPath("result.publicationDate").description("출판일").optional(), - fieldWithPath("result.mallType").description("상품 유형").optional(), - fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), - fieldWithPath("result.category").description("카테고리").optional(), - fieldWithPath("result.pages").description("페이지 수").optional(), - fieldWithPath("result.description").description("도서 설명").optional(), - fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), - fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), - fieldWithPath("result.sourceType").description("데이터 출처"), - fieldWithPath("result.bookShelfId").description("서재 ID").optional() - ) - ) - )); - } - - @Test - @DisplayName("사용자 도서 수정 성공") - void 사용자_도서_수정_성공() throws Exception { - BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() - .isbn13("1721329381232") - .bookId(101L) - .title("혼모노 수정") - .author("성해은") - .publisher("창비") - .publicationDate("2012-02-03") - .category("소설/시/희곡") - .pages(212) - .description("혼모노 수정") - .coverImageUrl("http://example.com/cover.jpg") - .sourceType(SourceType.USER) - .bookShelfId(5L) - .build(); - - given(userBookFacade.updateUserBook(any(User.class), anyLong(), any(BookRequestDto.UpdateUserBookRequest.class))) - .willReturn(response); - - MockMultipartFile cover = new MockMultipartFile( - "coverImage", "cover2.png", "image/png", "fake2".getBytes() - ); - - mockMvc.perform(multipart("/api/books/user/{bookId}", 101L) - .file(cover) - .param("title", "혼모노 수정") - .param("author", "성해은") - .param("categoryName", "소설/시/희곡") - .param("description", "소개수정") - .param("pages", "360") - .param("publisher", "민음사") - .param("publicationDate", "2024-01-01") - .param("isbn13", "9788936439743") - .with(request -> { - request.setMethod("PATCH"); - return request; - }) - .header("Authorization", "Bearer test-access-token") - .with(user(userDetails))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.bookId").value(101L)) - .andDo(documentWithAuth( - "{class-name}/{method-name}", - responseFields( - ApiResponseSnippet.withResult( - fieldWithPath("result.bookId").description("도서 ID"), - fieldWithPath("result.isbn13").description("ISBN13").optional(), - fieldWithPath("result.title").description("도서 제목"), - fieldWithPath("result.author").description("저자"), - fieldWithPath("result.publisher").description("출판사").optional(), - fieldWithPath("result.publicationDate").description("출판일").optional(), - fieldWithPath("result.mallType").description("상품 유형").optional(), - fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), - fieldWithPath("result.category").description("카테고리").optional(), - fieldWithPath("result.pages").description("페이지 수").optional(), - fieldWithPath("result.description").description("도서 설명").optional(), - fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), - fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), - fieldWithPath("result.sourceType").description("데이터 출처"), - fieldWithPath("result.bookShelfId").description("서재 ID").optional() - ) - ) - )); - } -} + package app.nook.controller.book; + + import app.nook.book.controller.BookController; + import app.nook.book.domain.enums.MallType; + import app.nook.book.domain.enums.SourceType; + import app.nook.book.dto.BookRequestDto; + import app.nook.book.dto.BookResponseDto; + import app.nook.book.exception.BookErrorCode; + import app.nook.book.facade.UserBookFacade; + import app.nook.book.service.BookService; + 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.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.mock.web.MockMultipartFile; + import org.springframework.test.context.bean.override.mockito.MockitoBean; + + import java.util.Arrays; + import java.util.Collections; + import java.util.List; + + import static org.mockito.ArgumentMatchers.*; + 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.pathParameters; + import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + @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; + + @MockitoBean + private UserBookFacade userBookFacade; + + @Test + @WithCustomUser + @DisplayName("ISBN으로 도서 상세 조회 성공") + void 도서_상세조회_성공() throws Exception { + String isbn13 = "9788936434267"; + + BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() + .bookId(1L) + .isbn13(isbn13) + .title("채식주의자") + .author("한강") + .publisher("창비") + .publicationDate("2007-10-30") + .mallType("도서") + .mallTypeCode(MallType.BOOK) + .category("소설") + .pages(184) + .description("한강의 소설") + .coverImageUrl("http://example.com/cover.jpg") + .aladinLink("http://aladin.com/book") + .sourceType(SourceType.ALADIN) + .bookShelfId(null) + .build(); + + given(bookService.getBookDetailByIsbn(any(), eq(isbn13))).willReturn(response); + + mockMvc.perform(get("/api/books/{isbn13}", isbn13).header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.title").value("채식주의자")) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + pathParameters(parameterWithName("isbn13").description("도서 ISBN13 (13자리 숫자)")), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.bookId").description("도서 ID"), + fieldWithPath("result.isbn13").description("ISBN13"), + fieldWithPath("result.title").description("도서 제목"), + fieldWithPath("result.author").description("저자"), + fieldWithPath("result.publisher").description("출판사"), + fieldWithPath("result.publicationDate").description("출판일"), + fieldWithPath("result.mallType").description("상품 유형"), + fieldWithPath("result.mallTypeCode").description("상품 유형 코드"), + fieldWithPath("result.category").description("카테고리"), + fieldWithPath("result.pages").description("페이지 수"), + fieldWithPath("result.description").description("도서 설명"), + fieldWithPath("result.coverImageUrl").description("표지 이미지 URL"), + fieldWithPath("result.aladinLink").description("알라딘 링크"), + fieldWithPath("result.sourceType").description("데이터 출처"), + fieldWithPath("result.bookShelfId").description("서재 ID (없으면 null)").optional() + )) + )); + } + + @Test + @WithCustomUser + @DisplayName("잘못된 ISBN 형식으로 조회 시 400 Bad Request") + void 도서_상세조회_실패_잘못된_ISBN_형식() throws Exception { + mockMvc.perform(get("/api/books/{isbn13}", "123").header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithCustomUser + @DisplayName("존재하지 않는 도서 조회 시 404 Not Found") + void 도서_상세조회_실패_존재하지않는_도서() throws Exception { + String isbn13 = "9999999999999"; + given(bookService.getBookDetailByIsbn(any(), eq(isbn13))) + .willThrow(new CustomException(BookErrorCode.BOOK_NOT_FOUND)); + + mockMvc.perform(get("/api/books/{isbn13}", isbn13).header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isNotFound()); + } + + @Test + @WithCustomUser + @DisplayName("bookId로 도서 상세 조회 성공") + void 도서_상세조회_bookId_성공() throws Exception { + BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() + .bookId(1L) + .isbn13("9788936434267") + .title("테스트책") + .author("저자") + .publisher("출판사") + .publicationDate("2023-03-21") + .mallType("국내도서") + .mallTypeCode(MallType.BOOK) + .category("소설/시/희곡") + .pages(312) + .description("한강의 소설") + .coverImageUrl("http://example.com/cover.jpg") + .sourceType(SourceType.USER) + .bookShelfId(3L) + .build(); + + given(bookService.getBookDetailById(any(), eq(1L))).willReturn(response); + + mockMvc.perform(get("/api/books/id/{bookId}", 1L).header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.bookId").value(1L)) + .andExpect(jsonPath("$.result.title").value("테스트책")) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + pathParameters(parameterWithName("bookId").description("도서 ID")), + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.bookId").description("도서 ID"), + fieldWithPath("result.isbn13").description("ISBN13").optional(), + fieldWithPath("result.title").description("도서 제목"), + fieldWithPath("result.author").description("저자"), + fieldWithPath("result.publisher").description("출판사").optional(), + fieldWithPath("result.publicationDate").description("출판일").optional(), + fieldWithPath("result.mallType").description("상품 유형").optional(), + fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), + fieldWithPath("result.category").description("카테고리").optional(), + fieldWithPath("result.pages").description("페이지 수").optional(), + fieldWithPath("result.description").description("도서 설명").optional(), + fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), + fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), + fieldWithPath("result.sourceType").description("데이터 출처"), + fieldWithPath("result.bookShelfId").description("서재 ID").optional() + )) + )); + } + + @Test + @WithCustomUser + @DisplayName("주간 베스트셀러 목록 조회 성공") + void 주간베스트셀러_조회_성공() throws Exception { + List response = Arrays.asList( + new BookResponseDto.BookPreviewDto("9788936434267", "채식주의자", "한강", "http://example.com/cover1.jpg", "창비", 1), + new BookResponseDto.BookPreviewDto("9788936433598", "소년이 온다", "한강", "http://example.com/cover2.jpg", "창비", 2) + ); + + given(bookService.getWeeklyBestsellers()).willReturn(response); + + mockMvc.perform(get("/api/books/bestsellers").header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result[0].title").value("채식주의자")) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result[].isbn13").description("ISBN13"), + fieldWithPath("result[].title").description("도서 제목"), + fieldWithPath("result[].author").description("저자"), + fieldWithPath("result[].coverImageUrl").description("표지 이미지 URL"), + fieldWithPath("result[].publisher").description("출판사"), + fieldWithPath("result[].rank").description("베스트셀러 순위") + )) + )); + } + + @Test + @WithCustomUser + @DisplayName("사용자 맞춤 추천 베스트셀러 조회 성공") + void 추천베스트셀러_조회_성공() throws Exception { + List response = Collections.singletonList( + new BookResponseDto.BookPreviewDto("9788936434267", "채식주의자", "한강", "http://example.com/cover1.jpg", "창비", 1) + ); + + given(bookService.getPersonalizedBestsellers(any(Long.class))).willReturn(response); + + mockMvc.perform(get("/api/books/recommendations").header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result[0].title").value("채식주의자")) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result[].isbn13").description("ISBN13"), + fieldWithPath("result[].title").description("도서 제목"), + fieldWithPath("result[].author").description("저자"), + fieldWithPath("result[].coverImageUrl").description("표지 이미지 URL"), + fieldWithPath("result[].publisher").description("출판사"), + fieldWithPath("result[].rank").description("베스트셀러 순위") + )) + )); + } + + @Test + @WithCustomUser + @DisplayName("사용자 도서 등록 성공") + void 사용자_도서_등록_성공() throws Exception { + BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() + .bookId(101L) + .title("혼모노") + .author("성해은") + .category("소설/시/희곡") + .sourceType(SourceType.USER) + .bookShelfId(5L) + .build(); + + given(userBookFacade.createUserBook(any(), any(BookRequestDto.CreateUserBookRequest.class))) + .willReturn(response); + + MockMultipartFile cover = new MockMultipartFile("coverImage", "cover.png", "image/png", "fake".getBytes()); + + mockMvc.perform(multipart("/api/books/user") + .file(cover) + .param("title", "혼모노") + .param("author", "성해은") + .param("categoryName", "소설/시/희곡") + .param("description", "소개") + .param("pages", "348") + .param("publisher", "민음사") + .param("publicationDate", "2023-06-05") + .param("isbn13", "9788936439743") + .header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS-201")) + .andExpect(jsonPath("$.result.bookId").value(101L)) + .andExpect(jsonPath("$.result.sourceType").value("USER")) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.bookId").description("도서 ID"), + fieldWithPath("result.isbn13").description("ISBN13").optional(), + fieldWithPath("result.title").description("도서 제목"), + fieldWithPath("result.author").description("저자"), + fieldWithPath("result.publisher").description("출판사").optional(), + fieldWithPath("result.publicationDate").description("출판일").optional(), + fieldWithPath("result.mallType").description("상품 유형").optional(), + fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), + fieldWithPath("result.category").description("카테고리").optional(), + fieldWithPath("result.pages").description("페이지 수").optional(), + fieldWithPath("result.description").description("도서 설명").optional(), + fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), + fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), + fieldWithPath("result.sourceType").description("데이터 출처"), + fieldWithPath("result.bookShelfId").description("서재 ID").optional() + )) + )); + } + + @Test + @WithCustomUser + @DisplayName("사용자 도서 수정 성공") + void 사용자_도서_수정_성공() throws Exception { + BookResponseDto.BookDetailDto response = BookResponseDto.BookDetailDto.builder() + .isbn13("1721329381232") + .bookId(101L) + .title("혼모노 수정") + .author("성해은") + .publisher("창비") + .publicationDate("2012-02-03") + .category("소설/시/희곡") + .pages(212) + .description("혼모노 수정") + .coverImageUrl("http://example.com/cover.jpg") + .sourceType(SourceType.USER) + .bookShelfId(5L) + .build(); + + given(userBookFacade.updateUserBook(any(), anyLong(), any(BookRequestDto.UpdateUserBookRequest.class))) + .willReturn(response); + + MockMultipartFile cover = new MockMultipartFile("coverImage", "cover2.png", "image/png", "fake2".getBytes()); + + mockMvc.perform(multipart("/api/books/user/{bookId}", 101L) + .file(cover) + .param("title", "혼모노 수정") + .param("author", "성해은") + .param("categoryName", "소설/시/희곡") + .param("description", "소개수정") + .param("pages", "360") + .param("publisher", "민음사") + .param("publicationDate", "2024-01-01") + .param("isbn13", "9788936439743") + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .header(AUTH_HEADER, AUTH_TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.bookId").value(101L)) + .andDo(documentWithAuth( + "{class-name}/{method-name}", + responseFields(ApiResponseSnippet.withResult( + fieldWithPath("result.bookId").description("도서 ID"), + fieldWithPath("result.isbn13").description("ISBN13").optional(), + fieldWithPath("result.title").description("도서 제목"), + fieldWithPath("result.author").description("저자"), + fieldWithPath("result.publisher").description("출판사").optional(), + fieldWithPath("result.publicationDate").description("출판일").optional(), + fieldWithPath("result.mallType").description("상품 유형").optional(), + fieldWithPath("result.mallTypeCode").description("상품 유형 코드").optional(), + fieldWithPath("result.category").description("카테고리").optional(), + fieldWithPath("result.pages").description("페이지 수").optional(), + fieldWithPath("result.description").description("도서 설명").optional(), + fieldWithPath("result.coverImageUrl").description("표지 이미지 URL").optional(), + fieldWithPath("result.aladinLink").description("알라딘 링크").optional(), + fieldWithPath("result.sourceType").description("데이터 출처"), + fieldWithPath("result.bookShelfId").description("서재 ID").optional() + )) + )); + } + } \ No newline at end of file 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 new file mode 100644 index 0000000..96417f2 --- /dev/null +++ b/src/test/java/app/nook/controller/library/LibraryStatsControllerTest.java @@ -0,0 +1,194 @@ +package app.nook.controller.library; + +import app.nook.focus.repository.FocusRepository; +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.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; + +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.test.web.servlet.result.MockMvcResultMatchers.status; + +@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; + + @MockitoBean + private FocusRepository focusRepository; + + @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) + ) + .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()); + } + } + } + + @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) + ) + .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/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/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/LibraryCachingIntegrationTest.java b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java new file mode 100644 index 0000000..cea7ec5 --- /dev/null +++ b/src/test/java/app/nook/library/service/LibraryCachingIntegrationTest.java @@ -0,0 +1,243 @@ +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.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.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; +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") +@WithCustomUser(userId = 1L) +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 = currentUserId(); + 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 = 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 = currentUser(); + + 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 = currentUserId(); + 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 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, + String coverImageUrl, + Long totalSec + ) { + return new FocusRepository.MonthlyFocusStatsProjection() { + @Override + public Integer getYearValue() { + return dateValue.getYear(); + } + + @Override + public Integer getMonthValue() { + return dateValue.getMonthValue(); + } + + @Override + public Integer getDayValue() { + return dateValue.getDayOfMonth(); + } + + @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..b8e3059 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,8 @@ 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; import org.springframework.data.domain.SliceImpl; @@ -49,6 +52,15 @@ class LibraryServiceTest { @Mock private BookTimeLineRepository bookTimeLineRepository; + @Mock + private FocusRepository focusRepository; + + @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 new file mode 100644 index 0000000..660e2d0 --- /dev/null +++ b/src/test/java/app/nook/library/service/LibraryStatsServiceTest.java @@ -0,0 +1,240 @@ +package app.nook.library.service; + +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; +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; + + 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 { + + @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); + } + } + + @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(); + + 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(); + } + } + } + + @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); + } + } + + @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(); + } + } + } +}