From 6e7c4484a01b87b0875ef3d62f5e356a1c85e820 Mon Sep 17 00:00:00 2001 From: Lee Ilseob Date: Sat, 15 Aug 2026 21:44:47 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(ai):=20Ask=20Chat=20=EC=98=88=EC=8B=9C?= =?UTF-8?q?=20=EC=A7=88=EB=AC=B8=2010=EB=B6=84=20=EB=A1=9C=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9=20(#225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Ask Chat 예시 질문 결정적 로테이션 추가 사용자 ID와 10분 시간 슬롯을 기준으로 예시 질문을 순환 선택하고 동일 슬롯 안정성 및 경계 동작을 단위 테스트로 검증함 * feat: Ask Chat 홈에 질문 로테이션 적용 AskChatSessionService의 요청별 랜덤 선택을 사용자별 10분 결정적 로테이션으로 교체함 --- .../askchat/api/AskChatSessionController.java | 1 + .../api/dto/response/AskChatHomeResponse.java | 2 +- .../application/AskChatSessionService.java | 38 ++----- .../helper/AskChatSampleQuestionSelector.java | 94 ++++++++++++++++ .../AskChatSessionServiceTest.java | 32 +++++- .../AskChatSampleQuestionSelectorTest.java | 100 ++++++++++++++++++ 6 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java index c9ae2f0a..08cf29c5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java @@ -47,6 +47,7 @@ public class AskChatSessionController { 홈 진입만으로 새 채팅 세션을 생성하지 않습니다.
사용자의 누적 답변 개수가 20개 이상인 경우에만 물어보기 홈을 조회할 수 있습니다.
응답에는 남은 메시지 횟수, 사용자 닉네임, 보유 크리스탈 수, 예시 질문 목록을 포함합니다.
+ 예시 질문은 사용자별로 10분 단위로 갱신되며 같은 시간 구간에는 동일하게 유지됩니다.
히스토리 목록은 이 API에서 반환하지 않으며, 별도 히스토리 API를 사용해야 합니다. """, security = @SecurityRequirement(name = "bearerAuth"), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java index ec516a0a..5d3a3542 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java @@ -15,7 +15,7 @@ public record AskChatHomeResponse( @Schema(description = "사용자가 보유한 크리스탈 개수", example = "100") long crystalBalance, - @Schema(description = "홈 화면에 표시할 예시 질문 목록. 여러 주제 중 일부를 랜덤으로 제공합니다.") + @Schema(description = "홈 화면에 표시할 예시 질문 목록. 사용자별로 10분 단위로 갱신됩니다.") List sampleQuestions ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java index a55f3bd7..09b618e3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; +import com.devkor.ifive.nadab.domain.askchat.application.helper.AskChatSampleQuestionSelector; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; @@ -12,7 +13,6 @@ import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; -import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; @@ -23,13 +23,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -37,11 +32,11 @@ public class AskChatSessionService { public static final int MAX_TURN_COUNT = 15; public static final int MIN_ANSWER_COUNT_TO_USE_ASK_CHAT = 20; - private static final int HOME_SAMPLE_QUESTION_SIZE = 3; private final AskChatSessionRepository askChatSessionRepository; private final AskChatWalletRepository askChatWalletRepository; private final AskChatSampleQuestionRepository askChatSampleQuestionRepository; + private final AskChatSampleQuestionSelector askChatSampleQuestionSelector; private final UserWalletRepository userWalletRepository; private final UserRepository userRepository; private final AnswerEntryRepository answerEntryRepository; @@ -61,7 +56,7 @@ public AskChatHomeResponse getHome(Long userId) { askChatWallet.getTotalTurnBalance(), user.getNickname(), userWallet.getCrystalBalance(), - pickSampleQuestions() + selectSampleQuestions(userId) ); } @@ -92,27 +87,14 @@ private AskChatSession createSession(Long userId) { return askChatSessionRepository.save(AskChatSession.start(user)); } - private List pickSampleQuestions() { - Map> questionsByCategory = askChatSampleQuestionRepository - .findByActiveTrueOrderByDisplayOrderAsc() - .stream() - .collect(Collectors.groupingBy( - AskChatSampleQuestion::getInterestCode, - LinkedHashMap::new, - Collectors.toList() - )); - - List categories = new ArrayList<>(questionsByCategory.keySet()); - Collections.shuffle(categories); + private List selectSampleQuestions(Long userId) { + List sampleQuestions = askChatSampleQuestionRepository + .findByActiveTrueOrderByDisplayOrderAsc(); - return categories.stream() - .limit(HOME_SAMPLE_QUESTION_SIZE) - .map(category -> pickOne(questionsByCategory.get(category))) + return askChatSampleQuestionSelector + .select(userId, Instant.now(), sampleQuestions) + .stream() .map(AskChatSampleQuestionResponse::from) .toList(); } - - private AskChatSampleQuestion pickOne(List sampleQuestions) { - return sampleQuestions.get(ThreadLocalRandom.current().nextInt(sampleQuestions.size())); - } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java new file mode 100644 index 00000000..07246af4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java @@ -0,0 +1,94 @@ +package com.devkor.ifive.nadab.domain.askchat.application.helper; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +public class AskChatSampleQuestionSelector { + + private static final int SAMPLE_QUESTION_SIZE = 3; + private static final long ROTATION_INTERVAL_SECONDS = Duration.ofMinutes(10).toSeconds(); + private static final long CATEGORY_SALT = 0x9E3779B97F4A7C15L; + + public List select( + Long userId, + Instant now, + List sampleQuestions + ) { + Map> questionsByCategory = groupByCategory(sampleQuestions); + if (questionsByCategory.isEmpty()) { + return List.of(); + } + + long rotationSlot = Math.floorDiv(now.getEpochSecond(), ROTATION_INTERVAL_SECONDS); + List categories = orderedCategories(userId, questionsByCategory); + int categoryStartIndex = rotatedIndex(mix(userId), rotationSlot, categories.size()); + int resultSize = Math.min(SAMPLE_QUESTION_SIZE, categories.size()); + + List selectedQuestions = new ArrayList<>(resultSize); + for (int index = 0; index < resultSize; index++) { + InterestCode category = categories.get((categoryStartIndex + index) % categories.size()); + List categoryQuestions = questionsByCategory.get(category); + int questionIndex = rotatedIndex( + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L))), + rotationSlot, + categoryQuestions.size() + ); + selectedQuestions.add(categoryQuestions.get(questionIndex)); + } + + return List.copyOf(selectedQuestions); + } + + private Map> groupByCategory( + List sampleQuestions + ) { + Map> questionsByCategory = + new EnumMap<>(InterestCode.class); + + for (AskChatSampleQuestion sampleQuestion : sampleQuestions) { + questionsByCategory + .computeIfAbsent(sampleQuestion.getInterestCode(), ignored -> new ArrayList<>()) + .add(sampleQuestion); + } + + Comparator questionOrder = Comparator + .comparingInt(AskChatSampleQuestion::getDisplayOrder) + .thenComparing(AskChatSampleQuestion::getQuestion); + questionsByCategory.values().forEach(questions -> questions.sort(questionOrder)); + return questionsByCategory; + } + + private List orderedCategories( + Long userId, + Map> questionsByCategory + ) { + List categories = new ArrayList<>(questionsByCategory.keySet()); + categories.sort(Comparator + .comparingLong((InterestCode category) -> + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L)))) + .thenComparing(InterestCode::name)); + return categories; + } + + private int rotatedIndex(long base, long rotationSlot, int size) { + int baseIndex = Math.floorMod(base, size); + int slotOffset = Math.floorMod(rotationSlot, size); + return (baseIndex + slotOffset) % size; + } + + private long mix(long value) { + value = (value ^ (value >>> 33)) * 0xff51afd7ed558ccdl; + value = (value ^ (value >>> 33)) * 0xc4ceb9fe1a85ec53l; + return value ^ (value >>> 33); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java index 0bad4036..fd5b5408 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java @@ -5,6 +5,7 @@ import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSessionStatus; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.application.helper.AskChatSampleQuestionSelector; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSampleQuestionRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; @@ -24,6 +25,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; @@ -32,6 +34,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -51,6 +55,9 @@ class AskChatSessionServiceTest { @Mock private AskChatSampleQuestionRepository askChatSampleQuestionRepository; + @Mock + private AskChatSampleQuestionSelector askChatSampleQuestionSelector; + @Mock private UserWalletRepository userWalletRepository; @@ -71,6 +78,7 @@ void setUp() { askChatSessionRepository, askChatWalletRepository, askChatSampleQuestionRepository, + askChatSampleQuestionSelector, userWalletRepository, userRepository, answerEntryRepository, @@ -86,11 +94,23 @@ void getHome_returns_home_display_data_without_creating_session() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(AskChatWallet.create(user, 2, 7))); when(userWalletRepository.findByUserId(1L)).thenReturn(Optional.of(UserWallet.create(user, 100L))); - when(askChatSampleQuestionRepository.findByActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of( + List sampleQuestions = List.of( AskChatSampleQuestion.create(InterestCode.VALUES, "나는 어떤 사람이야?", 1), AskChatSampleQuestion.create(InterestCode.PREFERENCE, "내가 좋아하는 것들의 공통점은 뭐야?", 2), AskChatSampleQuestion.create(InterestCode.RELATIONSHIP, "어떤 사람과 잘 맞을까?", 3) - )); + ); + List selectedQuestions = List.of( + sampleQuestions.get(2), + sampleQuestions.get(0), + sampleQuestions.get(1) + ); + when(askChatSampleQuestionRepository.findByActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(sampleQuestions); + when(askChatSampleQuestionSelector.select( + eq(1L), + any(Instant.class), + same(sampleQuestions) + )).thenReturn(selectedQuestions); var response = service.getHome(1L); @@ -100,7 +120,12 @@ void getHome_returns_home_display_data_without_creating_session() { assertThat(response.sampleQuestions()).hasSize(3); assertThat(response.sampleQuestions()) .extracting("category") - .containsExactlyInAnyOrder("VALUES", "PREFERENCE", "RELATIONSHIP"); + .containsExactly("RELATIONSHIP", "VALUES", "PREFERENCE"); + verify(askChatSampleQuestionSelector).select( + eq(1L), + any(Instant.class), + same(sampleQuestions) + ); verify(askChatSessionRepository, never()).save(any()); } @@ -115,6 +140,7 @@ void getHome_rejects_when_answer_count_is_less_than_minimum() { verifyNoInteractions(askChatWalletRepository); verifyNoInteractions(userWalletRepository); verifyNoInteractions(askChatSampleQuestionRepository); + verifyNoInteractions(askChatSampleQuestionSelector); verify(askChatSessionRepository, never()).save(any()); verifyNoInteractions(askChatMessageCommandService); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java new file mode 100644 index 00000000..cf60cf92 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.askchat.application.helper; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AskChatSampleQuestionSelectorTest { + + private final AskChatSampleQuestionSelector selector = new AskChatSampleQuestionSelector(); + + @Test + void select_returns_same_questions_within_same_ten_minute_slot() { + List questions = questionsForAllCategories(); + + var first = selector.select(1L, Instant.parse("2026-08-15T00:00:00Z"), questions); + var last = selector.select(1L, Instant.parse("2026-08-15T00:09:59Z"), questions); + + assertThat(last).containsExactlyElementsOf(first); + } + + @Test + void select_rotates_questions_when_ten_minute_slot_changes() { + List questions = questionsForAllCategories(); + + var before = selector.select(1L, Instant.parse("2026-08-15T00:09:59Z"), questions); + var after = selector.select(1L, Instant.parse("2026-08-15T00:10:00Z"), questions); + + assertThat(after).isNotEqualTo(before); + assertThat(after) + .extracting(AskChatSampleQuestion::getInterestCode) + .doesNotHaveDuplicates(); + } + + @Test + void select_distributes_questions_by_user() { + List questions = questionsForAllCategories(); + Instant now = Instant.parse("2026-08-15T00:05:00Z"); + + var firstUser = selector.select(1L, now, questions); + var secondUser = selector.select(2L, now, questions); + + assertThat(secondUser).isNotEqualTo(firstUser); + } + + @Test + void select_is_independent_of_input_order() { + List questions = questionsForAllCategories(); + List reversedQuestions = new ArrayList<>(questions); + Collections.reverse(reversedQuestions); + Instant now = Instant.parse("2026-08-15T00:05:00Z"); + + var originalOrder = selector.select(1L, now, questions); + var reversedOrder = selector.select(1L, now, reversedQuestions); + + assertThat(reversedOrder).containsExactlyElementsOf(originalOrder); + } + + @Test + void select_returns_available_categories_when_fewer_than_three_exist() { + List questions = List.of( + question(InterestCode.PREFERENCE, "취향 질문", 2), + question(InterestCode.VALUES, "가치관 질문", 1) + ); + + var selected = selector.select(1L, Instant.parse("2026-08-15T00:05:00Z"), questions); + + assertThat(selected).hasSize(2); + assertThat(selected) + .extracting(AskChatSampleQuestion::getInterestCode) + .containsExactlyInAnyOrder(InterestCode.PREFERENCE, InterestCode.VALUES); + } + + @Test + void select_returns_empty_list_when_no_questions_exist() { + var selected = selector.select(1L, Instant.parse("2026-08-15T00:05:00Z"), List.of()); + + assertThat(selected).isEmpty(); + } + + private List questionsForAllCategories() { + List questions = new ArrayList<>(); + int displayOrder = 1; + for (InterestCode category : InterestCode.values()) { + questions.add(question(category, category.name() + " 질문 A", displayOrder++)); + questions.add(question(category, category.name() + " 질문 B", displayOrder++)); + } + return questions; + } + + private AskChatSampleQuestion question(InterestCode category, String question, int displayOrder) { + return AskChatSampleQuestion.create(category, question, displayOrder); + } +} From 02d9ab5db9ad2e6dc8b376928bc3b651f0ea9988 Mon Sep 17 00:00:00 2001 From: Lee Ilseob Date: Thu, 20 Aug 2026 14:28:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(stats):=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EC=84=A0=ED=83=9D=EA=B3=BC=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20(#226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(stats): 유형 리포트 관심사별 일간 집계 추가 soft delete된 COMPLETED 이력을 누적·일별 통계에 포함하고 최근 7일의 빈 날짜·관심사를 0으로 보정함 * feat(stats): 유형 리포트 관심사별 추이 차트 추가 * feat(stats): 통계 조회 기준 기간 선택 지원 date·week·month 쿼리 파라미터를 ISO 형식으로 해석하고 미래 기간을 차단함. 선택 기간을 끝점으로 일간 7일·주간 5주·월간 5개월 시계열과 요약값을 구성하고 실제 현재 기간을 기준으로 최고 기록 상태를 유지함 * feat(stats): 통계 기간 선택 UI와 요약 카드 추가 일·주·월 통계 페이지 하단에 기간 선택 폼과 현재 기간 복귀 링크를 추가함 * feat(stats): 유형 리포트 활성·누적 통계 분리 현재 활성 COMPLETED 리포트와 재생성 이력을 포함한 전체 누적 생성 수를 별도 집계·ViewModel·차트로 분리함 * fix(infra): 테스트 DB 커넥션 풀 연결 한도 초과 수정 --- .../stats/application/DailyStatsService.java | 27 ++- .../application/MonthlyStatsService.java | 20 +- .../stats/application/TypeStatsService.java | 71 +++++- .../stats/application/WeeklyStatsService.java | 23 +- .../helper/StatsPeriodResolver.java | 102 +++++++++ .../stats/controller/StatsController.java | 31 ++- .../dto/daily/DailyPeriodStatsViewModel.java | 10 + .../core/dto/daily/DailyStatsViewModel.java | 2 +- .../monthly/MonthlyPeriodStatsViewModel.java | 13 ++ .../dto/monthly/MonthlyStatsViewModel.java | 1 + .../type/TypeReportDateInterestCountDto.java | 11 + .../dto/type/TypeReportInterestCountDto.java | 8 + .../TypeReportInterestSeriesViewModel.java | 9 + .../core/dto/type/TypeStatsViewModel.java | 5 +- .../weekly/WeeklyPeriodStatsViewModel.java | 11 + .../core/dto/weekly/WeeklyStatsViewModel.java | 1 + .../core/repository/DailyStatsRepository.java | 13 +- .../core/repository/TypeStatsRepository.java | 58 ++++- .../typereport/api/TypeReportController.java | 2 +- .../nadab/domain/user/api/UserController.java | 2 +- .../domain/user/core/entity/InterestCode.java | 2 +- ...0_1200__IS_add_type_report_stats_index.sql | 3 + .../resources/static/css/stats-period.css | 208 ++++++++++++++++++ src/main/resources/templates/stats/daily.html | 11 +- .../stats/fragments/period-summary.html | 124 +++++++++++ .../resources/templates/stats/monthly.html | 3 + src/main/resources/templates/stats/type.html | 157 ++++++++++--- .../resources/templates/stats/weekly.html | 3 + .../application/DailyStatsServiceTest.java | 36 ++- .../application/MonthlyStatsServiceTest.java | 51 +++-- .../application/TypeStatsServiceTest.java | 70 ++++++ .../application/WeeklyStatsServiceTest.java | 47 ++-- .../helper/StatsPeriodResolverTest.java | 50 +++++ .../StatsControllerTemplateTest.java | 112 +++++++++- .../repository/DailyStatsRepositoryTest.java | 59 +++++ .../repository/TypeStatsRepositoryTest.java | 99 +++++++++ src/test/resources/application-test.yml | 5 + 37 files changed, 1310 insertions(+), 150 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java create mode 100644 src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql create mode 100644 src/main/resources/static/css/stats-period.css create mode 100644 src/main/resources/templates/stats/fragments/period-summary.html create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolverTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepositoryTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepositoryTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java index 435c4b25..307bf53c 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; @@ -26,33 +27,34 @@ public class DailyStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int DAILY_CHART_SIZE = 7; - public DailyStatsViewModel getDailyStatsLast7Days() { + public DailyStatsViewModel getDailyStats(LocalDate selectedDate) { LocalDate today = TodayDateTimeProvider.getTodayDate(); - LocalDate startDate = today.minusDays(6); + LocalDate startDate = selectedDate.minusDays(DAILY_CHART_SIZE - 1L); // 라벨 7개 고정 생성 List days = new ArrayList<>(); - for (int i = 0; i < 7; i++) days.add(startDate.plusDays(i)); + for (int i = 0; i < DAILY_CHART_SIZE; i++) days.add(startDate.plusDays(i)); List labels = days.stream().map(LocalDate::toString).toList(); // 1) 가입자 수 Map signupMap = new HashMap<>(); - for (Object[] row : repo.findSignupCountsLast7Days(startDate, today)) { + for (Object[] row : repo.findSignupCountsLast7Days(startDate, selectedDate)) { DateCountDto dto = DailyStatsRepository.toDateCountDto(row); signupMap.put(dto.date(), dto.count()); } // 2) 할당 질문 수 Map assignedMap = new HashMap<>(); - for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, selectedDate)) { assignedMap.put(dto.date(), dto.count()); } // 3) COMPLETED 리포트 수 Map completedMap = new HashMap<>(); - for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, selectedDate)) { completedMap.put(dto.date(), dto.count()); } @@ -71,17 +73,26 @@ public DailyStatsViewModel getDailyStatsLast7Days() { PeakMetric.DAU, days, completedCounts, today ); - long sharedNow = repo.countSharedDailyReportsNow(); + int selectedIndex = days.size() - 1; + long sharedDailyReportCount = repo.countSharedDailyReports(selectedDate); + DailyPeriodStatsViewModel selectedPeriod = new DailyPeriodStatsViewModel( + selectedDate.toString(), + selectedDate.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + sharedDailyReportCount + ); return new DailyStatsViewModel( labels, signupCounts, assignedCounts, completedCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, dauPeak, - sharedNow, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java index c65a392a..d83208b5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; @@ -34,13 +35,13 @@ public class MonthlyStatsService { DateTimeFormatter.ofPattern("yyyy-MM"); private static final int MONTHLY_CHART_SIZE = 5; - public MonthlyStatsViewModel getMonthlyStatsLast5Months() { + public MonthlyStatsViewModel getMonthlyStats(YearMonth selectedMonth) { LocalDate today = TodayDateTimeProvider.getTodayDate(); YearMonth currentMonth = YearMonth.from(today); - YearMonth startMonth = currentMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); + YearMonth startMonth = selectedMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); LocalDate startDate = startMonth.atDay(1); - LocalDate endDateInclusive = currentMonth.atEndOfMonth(); + LocalDate endDateInclusive = selectedMonth.atEndOfMonth(); List months = new ArrayList<>(); for (int i = 0; i < MONTHLY_CHART_SIZE; i++) { @@ -99,6 +100,18 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); long inProgressTotalNow = inProgressV1Now + inProgressV2Now; + int selectedIndex = months.size() - 1; + MonthlyPeriodStatsViewModel selectedPeriod = new MonthlyPeriodStatsViewModel( + selectedMonth.toString(), + selectedMonth.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedV1Counts.get(selectedIndex), + completedV2Counts.get(selectedIndex), + completedTotalCounts.get(selectedIndex), + mauCounts.get(selectedIndex) + ); return new MonthlyStatsViewModel( labels, @@ -109,6 +122,7 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { completedV2Counts, completedTotalCounts, mauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java index f88ee9d7..b598b151 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java @@ -1,15 +1,24 @@ package com.devkor.ifive.nadab.domain.stats.application; -import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestSeriesViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.TypeStatsRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -20,22 +29,70 @@ public class TypeStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int RECENT_DAYS = 7; public TypeStatsViewModel getTypeStats() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate startDate = today.minusDays(RECENT_DAYS - 1L); + long inProgressTypeReportCount = repo.countInProgressTypeReportsNow(); - List completedByInterest = repo.countCompletedTypeReportsByInterest(); + List activeCompletedByInterest = + repo.countActiveCompletedTypeReportsByInterest(); + List completedHistoryByInterest = + repo.countCompletedTypeReportHistoryByInterest(); + List completedByDateAndInterest = + repo.countCompletedTypeReportsByDateAndInterest(startDate, today); + + Map activeCompletedCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : activeCompletedByInterest) { + activeCompletedCountMap.put(dto.interestCode(), dto.count()); + } - List interestLabels = completedByInterest.stream() - .map(LabelCountDto::label) + Map completedHistoryCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : completedHistoryByInterest) { + completedHistoryCountMap.put(dto.interestCode(), dto.count()); + } + + List interests = List.of(InterestCode.values()); + List interestLabels = interests.stream() + .map(InterestCode::displayNameKo) + .toList(); + List activeCompletedTypeReportCounts = interests.stream() + .map(interest -> activeCompletedCountMap.getOrDefault(interest, 0L)) .toList(); - List completedTypeReportCounts = completedByInterest.stream() - .map(LabelCountDto::count) + List cumulativeCompletedTypeReportCounts = interests.stream() + .map(interest -> completedHistoryCountMap.getOrDefault(interest, 0L)) + .toList(); + + List recentDates = new ArrayList<>(RECENT_DAYS); + for (int i = 0; i < RECENT_DAYS; i++) { + recentDates.add(startDate.plusDays(i)); + } + + Map> dailyCountMap = new HashMap<>(); + for (TypeReportDateInterestCountDto dto : completedByDateAndInterest) { + dailyCountMap.computeIfAbsent(dto.date(), ignored -> new EnumMap<>(InterestCode.class)) + .put(dto.interestCode(), dto.count()); + } + + List completedTypeReportSeries = interests.stream() + .map(interest -> new TypeReportInterestSeriesViewModel( + interest.name(), + interest.displayNameKo(), + recentDates.stream() + .map(date -> dailyCountMap.getOrDefault(date, Map.of()) + .getOrDefault(interest, 0L)) + .toList() + )) .toList(); return new TypeStatsViewModel( inProgressTypeReportCount, interestLabels, - completedTypeReportCounts, + activeCompletedTypeReportCounts, + cumulativeCompletedTypeReportCounts, + recentDates.stream().map(LocalDate::toString).toList(), + completedTypeReportSeries, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java index 5b67a51a..c0d77222 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -33,15 +35,17 @@ public class WeeklyStatsService { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter WEEK_LABEL_FMT = DateTimeFormatter.ofPattern("MM-dd"); + private static final int WEEKLY_CHART_SIZE = 5; - public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { + public WeeklyStatsViewModel getWeeklyStats(LocalDate selectedWeek) { LocalDate today = TodayDateTimeProvider.getTodayDate(); LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate startWeekStart = currentWeekStart.minusWeeks(6); - LocalDate endDateInclusive = currentWeekStart.plusDays(6); + LocalDate selectedWeekStart = selectedWeek.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate startWeekStart = selectedWeekStart.minusWeeks(WEEKLY_CHART_SIZE - 1L); + LocalDate endDateInclusive = selectedWeekStart.plusDays(6); List weekStarts = new ArrayList<>(); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < WEEKLY_CHART_SIZE; i++) { weekStarts.add(startWeekStart.plusWeeks(i)); } @@ -89,6 +93,16 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { ); long inProgressNow = repo.countInProgressWeeklyReportsNow(); + int selectedIndex = weekStarts.size() - 1; + WeeklyPeriodStatsViewModel selectedPeriod = new WeeklyPeriodStatsViewModel( + StatsPeriodResolver.formatIsoWeek(selectedWeekStart), + selectedWeekStart + " ~ " + selectedWeekStart.plusDays(6), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + wauCounts.get(selectedIndex) + ); return new WeeklyStatsViewModel( labels, @@ -97,6 +111,7 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { completedDailyCounts, completedCounts, wauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java new file mode 100644 index 00000000..1aa33def --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java @@ -0,0 +1,102 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.IsoFields; +import java.time.temporal.TemporalAdjusters; +import java.util.Locale; + +public final class StatsPeriodResolver { + + private static final DateTimeFormatter ISO_WEEK_FORMATTER = new DateTimeFormatterBuilder() + .appendValue(IsoFields.WEEK_BASED_YEAR, 4) + .appendLiteral("-W") + .appendValue(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2) + .toFormatter(Locale.ROOT); + + private StatsPeriodResolver() {} + + public static LocalDate resolveDaily(String value) { + return resolveDaily(value, TodayDateTimeProvider.getTodayDate()); + } + + public static LocalDate resolveWeekly(String value) { + return resolveWeekly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static YearMonth resolveMonthly(String value) { + return resolveMonthly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static String formatIsoWeek(LocalDate weekStart) { + return ISO_WEEK_FORMATTER.format( + weekStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + ); + } + + static LocalDate resolveDaily(String value, LocalDate today) { + LocalDate selectedDate = isBlank(value) ? today : parseDate(value); + if (selectedDate.isAfter(today)) { + throw invalidPeriod(); + } + return selectedDate; + } + + static LocalDate resolveWeekly(String value, LocalDate today) { + LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate selectedWeekStart = isBlank(value) ? currentWeekStart : parseWeekStart(value); + if (selectedWeekStart.isAfter(currentWeekStart)) { + throw invalidPeriod(); + } + return selectedWeekStart; + } + + static YearMonth resolveMonthly(String value, LocalDate today) { + YearMonth currentMonth = YearMonth.from(today); + YearMonth selectedMonth = isBlank(value) ? currentMonth : parseMonth(value); + if (selectedMonth.isAfter(currentMonth)) { + throw invalidPeriod(); + } + return selectedMonth; + } + + private static LocalDate parseDate(String value) { + try { + return LocalDate.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static LocalDate parseWeekStart(String value) { + try { + return LocalDate.parse(value + "-1", DateTimeFormatter.ISO_WEEK_DATE); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static YearMonth parseMonth(String value) { + try { + return YearMonth.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static BadRequestException invalidPeriod() { + return new BadRequestException(ErrorCode.VALIDATION_FAILED); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index 2e5a18ed..2a76748f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; @@ -16,6 +17,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.YearMonth; @Controller @RequiredArgsConstructor @@ -29,25 +34,37 @@ public class StatsController { private final WithdrawalStatsService withdrawalStatsService; - @GetMapping("stats/daily") - public String dailyStats(Model model) { - DailyStatsViewModel vm = dailyStatsService.getDailyStatsLast7Days(); + @GetMapping("/stats/daily") + public String dailyStats( + @RequestParam(required = false) String date, + Model model + ) { + LocalDate selectedDate = StatsPeriodResolver.resolveDaily(date); + DailyStatsViewModel vm = dailyStatsService.getDailyStats(selectedDate); model.addAttribute("vm", vm); model.addAttribute("activeTab", "daily"); return "stats/daily"; } @GetMapping("/stats/weekly") - public String weeklyStats(Model model) { - WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStatsLast7Weeks(); + public String weeklyStats( + @RequestParam(required = false) String week, + Model model + ) { + LocalDate selectedWeekStart = StatsPeriodResolver.resolveWeekly(week); + WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStats(selectedWeekStart); model.addAttribute("vm", vm); model.addAttribute("activeTab", "weekly"); return "stats/weekly"; } @GetMapping("/stats/monthly") - public String monthlyStats(Model model) { - MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStatsLast5Months(); + public String monthlyStats( + @RequestParam(required = false) String month, + Model model + ) { + YearMonth selectedMonth = StatsPeriodResolver.resolveMonthly(month); + MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStats(selectedMonth); model.addAttribute("vm", vm); model.addAttribute("activeTab", "monthly"); return "stats/monthly"; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java new file mode 100644 index 00000000..269f29bf --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.daily; + +public record DailyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long dauCount, + long sharedDailyReportCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java index 00776dfc..07f801f2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java @@ -9,9 +9,9 @@ public record DailyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, + DailyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel dauPeak, - long sharedDailyReportCount, String refreshedAt // "2026-02-27 21:34:12" ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java new file mode 100644 index 00000000..9091d68d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.monthly; + +public record MonthlyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedMonthlyReportV1Count, + long completedMonthlyReportV2Count, + long completedMonthlyReportTotalCount, + long mauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java index 0abca917..fe3e9390 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java @@ -13,6 +13,7 @@ public record MonthlyStatsViewModel( List completedMonthlyReportV2Counts, List completedMonthlyReportTotalCounts, List mauCounts, + MonthlyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java new file mode 100644 index 00000000..5d6ee77f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.LocalDate; + +public record TypeReportDateInterestCountDto( + LocalDate date, + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java new file mode 100644 index 00000000..81f5eccd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +public record TypeReportInterestCountDto( + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java new file mode 100644 index 00000000..3fcf1171 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import java.util.List; + +public record TypeReportInterestSeriesViewModel( + String interestCode, + String interestLabel, + List counts +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java index c2e6042f..89ab86e6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java @@ -5,6 +5,9 @@ public record TypeStatsViewModel( long inProgressTypeReportCount, List interestLabels, - List completedTypeReportCounts, + List activeCompletedTypeReportCounts, + List cumulativeCompletedTypeReportCounts, + List recentDateLabels, + List completedTypeReportSeries, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java new file mode 100644 index 00000000..ecc9951b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.weekly; + +public record WeeklyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedWeeklyReportCount, + long wauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java index 5e79d50f..41a30b52 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java @@ -11,6 +11,7 @@ public record WeeklyStatsViewModel( List completedDailyReportCounts, List completedWeeklyReportCounts, List wauCounts, + WeeklyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java index 756a5f35..419011eb 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -1,7 +1,6 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; -import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -76,21 +75,15 @@ public List findCompletedDailyReportCountsLast7Days(LocalDate star .getResultList(); } - /** - * 현재 공유 중(isShared=true) daily_reports 개수 - * - 보통 COMPLETED만 공유 의미가 있으니 status도 거는 걸 추천 - */ - public long countSharedDailyReportsNow() { - LocalDate today = TodayDateTimeProvider.getTodayDate(); - + public long countSharedDailyReports(LocalDate date) { return em.createQuery(""" select count(dr.id) from DailyReport dr - where dr.date = :today + where dr.date = :date and dr.isShared = true and dr.status = 'COMPLETED' """, Long.class) - .setParameter("today", today) + .setParameter("date", date) .getSingleResult(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java index 22895953..dc96a2fc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; -import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; @Repository @@ -23,19 +25,53 @@ select count(tr.id) .getSingleResult(); } - public List countCompletedTypeReportsByInterest() { + public List countActiveCompletedTypeReportsByInterest() { return em.createQuery(""" - select new com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto( - cast(i.code as string), + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, count(tr.id) ) - from Interest i - left join TypeReport tr on tr.interestCode = i.code - and tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED - and tr.deletedAt is null - group by i.code - order by i.code - """, LabelCountDto.class) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.deletedAt is null + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportHistoryByInterest() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportsByDateAndInterest( + LocalDate startDate, + LocalDate endDateInclusive + ) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto( + tr.date, + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.date between :startDate and :endDate + group by tr.date, tr.interestCode + order by tr.date, tr.interestCode + """, TypeReportDateInterestCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) .getResultList(); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java index b8e226d6..4f93ef11 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java @@ -43,7 +43,7 @@ public class TypeReportController { - **PREFERENCE** : 취향 - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : 사랑 - **VALUES** : 가치관 """, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java index e80c022d..e2ae9eb2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java @@ -241,7 +241,7 @@ public ResponseEntity> checkNickname( - **PREFERENCE** : 취향 - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : 사랑 - **VALUES** : 가치관 """, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java index f9566376..81d566ed 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java @@ -28,7 +28,7 @@ public String displayNameKo() { case PREFERENCE -> "취향"; case EMOTION -> "감정"; case ROUTINE -> "루틴"; - case RELATIONSHIP -> "인간관계"; + case RELATIONSHIP -> "관계"; case LOVE -> "사랑"; case VALUES -> "가치관"; }; diff --git a/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql new file mode 100644 index 00000000..dcd74e86 --- /dev/null +++ b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_type_reports_completed_date_interest + ON type_reports (date, interest_code) + WHERE status = 'COMPLETED'; diff --git a/src/main/resources/static/css/stats-period.css b/src/main/resources/static/css/stats-period.css new file mode 100644 index 00000000..6c1a2715 --- /dev/null +++ b/src/main/resources/static/css/stats-period.css @@ -0,0 +1,208 @@ +.period-panel { + margin-bottom: 20px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + animation: fadeUp .35s ease both; +} + +.period-panel-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px 24px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} + +.period-panel-eyebrow { + margin-bottom: 5px; + color: var(--accent); + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.period-panel-title { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.25rem; + font-weight: 500; +} + +.period-picker { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.period-picker-field { + display: grid; + gap: 5px; +} + +.period-picker-label { + color: var(--text-2); + font-size: .68rem; + font-weight: 600; +} + +.period-picker-input { + min-height: 36px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-2); + color: var(--text-1); + color-scheme: dark; + font-family: 'DM Mono', monospace; + font-size: .76rem; + transition: border-color .2s, box-shadow .2s; +} + +.period-picker-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 143, 255, .12); +} + +.period-picker-submit, +.period-picker-current { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + padding: 7px 13px; + border-radius: var(--radius-sm); + font-family: 'Syne', sans-serif; + font-size: .74rem; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.period-picker-submit { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; +} + +.period-picker-current { + border: 1px solid var(--border); + background: transparent; + color: var(--text-2); + transition: border-color .2s, color .2s, background .2s; +} + +.period-picker-current:hover { + border-color: var(--accent); + background: rgba(108, 143, 255, .08); + color: var(--accent); +} + +.period-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + background: var(--border); +} + +.period-summary-item { + min-width: 0; + padding: 17px 20px 18px; + background: var(--surface); +} + +.period-summary-label { + margin-bottom: 7px; + color: var(--text-2); + font-size: .7rem; + font-weight: 600; + line-height: 1.35; +} + +.period-summary-value { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.55rem; + font-weight: 500; + line-height: 1; +} + +.period-summary-breakdown { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + margin-top: 8px; + color: var(--text-2); + font-family: 'DM Mono', monospace; + font-size: .66rem; +} + +.period-summary-breakdown strong { + margin-left: 4px; + color: var(--text-1); + font-weight: 500; +} + +@media (max-width: 680px) { + .page-header { + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .header-right { + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px 12px; + } + + .tabs { + width: 100%; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab-link { + flex: 0 0 auto; + padding: 7px 12px; + font-size: .76rem; + } + + .period-panel-header { + align-items: stretch; + flex-direction: column; + } + + .period-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .period-picker-input { + width: 100%; + } + + .period-summary-item:last-child:nth-child(odd) { + grid-column: 1 / -1; + } +} + +@media (max-width: 440px) { + .period-picker { + grid-template-columns: 1fr 1fr; + } + + .period-picker-field { + grid-column: 1 / -1; + } +} diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index db8502b5..e34109c6 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -244,6 +244,7 @@ .chart-card:nth-child(3) { animation-delay: .15s; } + @@ -289,14 +290,6 @@ th:href="@{/stats/total}">전체 - -
-
-
현재 공유 중인 일간 리포트
-
0
-
-
-
@@ -348,6 +341,8 @@
+
+ + + diff --git a/src/main/resources/templates/admin/version-history.html b/src/main/resources/templates/admin/version-history.html index 39bbe632..93b845e7 100644 --- a/src/main/resources/templates/admin/version-history.html +++ b/src/main/resources/templates/admin/version-history.html @@ -279,6 +279,7 @@

Admin Console

diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html index c534a66a..3733398a 100644 --- a/src/main/resources/templates/admin/version.html +++ b/src/main/resources/templates/admin/version.html @@ -305,6 +305,7 @@

Admin Console

diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java new file mode 100644 index 00000000..349376e9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminAskChatWalletLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatWalletLogControllerTest { + + @Mock + private AdminAskChatWalletLogQueryService adminAskChatWalletLogQueryService; + + @InjectMocks + private AdminAskChatWalletLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminAskChatWalletLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " Alice ", " alice@example.com "); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminAskChatWalletLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java new file mode 100644 index 00000000..05968d7f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminCrystalLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminCrystalLogControllerTest { + + @Mock + private AdminCrystalLogQueryService adminCrystalLogQueryService; + + @InjectMocks + private AdminCrystalLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminCrystalLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " Alice ", " alice@example.com "); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminCrystalLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java new file mode 100644 index 00000000..f4b3c6e8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminPageControllerTest { + + @Test + void returns_admin_logs_template_for_logs_tab() { + AdminPageController controller = new AdminPageController(); + + assertThat(controller.adminLogsPage()).isEqualTo("admin/logs"); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java new file mode 100644 index 00000000..78fd0d6b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminReportGenerationLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminReportGenerationLogControllerTest { + + @Mock + private AdminReportGenerationLogQueryService adminReportGenerationLogQueryService; + + @InjectMocks + private AdminReportGenerationLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminReportGenerationLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " alice ", "alice@example.com"); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminReportGenerationLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java new file mode 100644 index 00000000..9c826f1d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java @@ -0,0 +1,44 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminLogPageResponseTest { + + @Test + void maps_spring_page_to_admin_log_page_response() { + AdminLogPageResponse response = AdminLogPageResponse.from( + new PageImpl<>( + List.of("log-2"), + PageRequest.of(1, 1), + 3 + ) + ); + + assertThat(response.items()).containsExactly("log-2"); + assertThat(response.totalCount()).isEqualTo(3); + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(1); + assertThat(response.totalPages()).isEqualTo(3); + assertThat(response.hasPrevious()).isTrue(); + assertThat(response.hasNext()).isTrue(); + } + + @Test + void reports_no_navigation_for_empty_page() { + AdminLogPageResponse response = AdminLogPageResponse.from( + new PageImpl<>(List.of(), PageRequest.of(0, 20), 0) + ); + + assertThat(response.items()).isEmpty(); + assertThat(response.totalCount()).isZero(); + assertThat(response.totalPages()).isZero(); + assertThat(response.hasPrevious()).isFalse(); + assertThat(response.hasNext()).isFalse(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java new file mode 100644 index 00000000..76bd8823 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java @@ -0,0 +1,87 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogStatus; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatWalletLogQueryServiceTest { + + @Mock + private AskChatWalletLogRepository askChatWalletLogRepository; + + @Test + void maps_ask_chat_wallet_logs_to_admin_page_response() { + AdminAskChatWalletLogQueryService service = + new AdminAskChatWalletLogQueryService(askChatWalletLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + AskChatWalletLog log = AskChatWalletLog.createPending( + user, + null, + null, + 0, + -1, + 2, + 9, + AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME, + "ASK_CHAT_MESSAGE", + 501L, + "ask-chat-message-501" + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(askChatWalletLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.sessionId()).isNull(); + assertThat(item.messageId()).isNull(); + assertThat(item.freeTurnDelta()).isZero(); + assertThat(item.paidTurnDelta()).isEqualTo(-1); + assertThat(item.freeTurnBalanceAfter()).isEqualTo(2); + assertThat(item.paidTurnBalanceAfter()).isEqualTo(9); + assertThat(item.reason()).isEqualTo(AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME); + assertThat(item.status()).isEqualTo(AskChatWalletLogStatus.PENDING); + assertThat(item.refType()).isEqualTo("ASK_CHAT_MESSAGE"); + assertThat(item.refId()).isEqualTo(501L); + assertThat(item.idempotencyKey()).isEqualTo("ask-chat-message-501"); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(askChatWalletLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java new file mode 100644 index 00000000..e1384df8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java @@ -0,0 +1,76 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminCrystalLogQueryServiceTest { + + @Mock + private CrystalLogRepository crystalLogRepository; + + @Test + void maps_crystal_logs_to_admin_page_response() { + AdminCrystalLogQueryService service = new AdminCrystalLogQueryService(crystalLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + CrystalLog log = CrystalLog.createPending( + user, + -100L, + 900L, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + "MONTHLY_REPORT", + 501L + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(crystalLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.delta()).isEqualTo(-100L); + assertThat(item.balanceAfter()).isEqualTo(900L); + assertThat(item.reason()).isEqualTo(CrystalLogReason.REPORT_GENERATE_MONTHLY); + assertThat(item.status()).isEqualTo(CrystalLogStatus.PENDING); + assertThat(item.refType()).isEqualTo("MONTHLY_REPORT"); + assertThat(item.refId()).isEqualTo(501L); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(crystalLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java new file mode 100644 index 00000000..23c75a9f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +class AdminLogSearchConditionTest { + + @Test + void normalizes_search_values_and_builds_latest_first_pageable() { + AdminLogSearchCondition condition = AdminLogSearchCondition.of( + 2, + 30, + " NaDaB ", + " " + ); + + assertThat(condition.page()).isEqualTo(2); + assertThat(condition.size()).isEqualTo(30); + assertThat(condition.nickname()).isEqualTo("nadab"); + assertThat(condition.email()).isNull(); + assertThat(condition.toPageable().getPageNumber()).isEqualTo(1); + assertThat(condition.toPageable().getPageSize()).isEqualTo(30); + assertThat(condition.toPageable().getSort().toList()) + .extracting(Sort.Order::getProperty, Sort.Order::getDirection) + .containsExactly( + tuple("createdAt", Sort.Direction.DESC), + tuple("id", Sort.Direction.DESC) + ); + } + + @Test + void rejects_invalid_page_request() { + assertThatThrownBy(() -> AdminLogSearchCondition.of(0, 20, null, null)) + .isInstanceOfSatisfying(BadRequestException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_FAILED)); + assertThatThrownBy(() -> AdminLogSearchCondition.of(1, 0, null, null)) + .isInstanceOf(BadRequestException.class); + assertThatThrownBy(() -> AdminLogSearchCondition.of(1, 51, null, null)) + .isInstanceOf(BadRequestException.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java new file mode 100644 index 00000000..710a7499 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java @@ -0,0 +1,75 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminReportGenerationLogQueryServiceTest { + + @Mock + private ReportGenerationLogRepository reportGenerationLogRepository; + + @Test + void maps_report_generation_logs_to_admin_page_response() { + AdminReportGenerationLogQueryService service = + new AdminReportGenerationLogQueryService(reportGenerationLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + ReportGenerationLog log = ReportGenerationLog.start( + user, + ReportGenerationType.MONTHLY_V2, + 501L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(reportGenerationLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.reportType()).isEqualTo(ReportGenerationType.MONTHLY_V2); + assertThat(item.reportId()).isEqualTo(501L); + assertThat(item.step()).isEqualTo(ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(reportGenerationLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java new file mode 100644 index 00000000..9ed2cdd3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java @@ -0,0 +1,139 @@ +package com.devkor.ifive.nadab.domain.askchat.core.repository; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Comparator; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class AskChatWalletLogRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + AskChatWalletLogRepository askChatWalletLogRepository; + + @Autowired + TestEntityManager em; + + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + AskChatWalletLog matchingLog = askChatWalletLogRepository.save( + confirmedLog(matchingUser, -1, 0, 2, 9, 301L, "ask-filter-301") + ); + askChatWalletLogRepository.save( + confirmedLog(sameNicknameUser, 0, 10, 3, 10, 302L, "ask-filter-302") + ); + askChatWalletLogRepository.save( + confirmedLog(otherUser, 3, 0, 3, 0, 303L, "ask-filter-303") + ); + + em.flush(); + em.clear(); + + // when + Page logs = askChatWalletLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(AskChatWalletLog::getRefId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + assertThat(logs.getContent().get(0).getFreeTurnDelta()).isEqualTo(-1); + assertThat(logs.getContent().get(0).getPaidTurnDelta()).isZero(); + assertThat(matchingLog.getId()).isNotNull(); + } + + @Test + void find_all_for_admin_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + AskChatWalletLog first = askChatWalletLogRepository.save( + confirmedLog(user, 3, 0, 3, 0, 401L, "ask-order-401") + ); + AskChatWalletLog second = askChatWalletLogRepository.save( + confirmedLog(user, 0, -1, 3, 9, 402L, "ask-order-402") + ); + + em.flush(); + em.clear(); + + // when + Page logs = askChatWalletLogRepository.findAllForAdmin( + null, + null, + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(2); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(AskChatWalletLog::getCreatedAt) + .thenComparing(AskChatWalletLog::getId) + .reversed() + ); + assertThat(logs.getContent()).extracting(AskChatWalletLog::getId) + .containsExactly(second.getId(), first.getId()); + } + + private AskChatWalletLog confirmedLog( + User user, + int freeTurnDelta, + int paidTurnDelta, + int freeTurnBalanceAfter, + int paidTurnBalanceAfter, + Long refId, + String idempotencyKey + ) { + return AskChatWalletLog.createConfirmed( + user, + null, + null, + freeTurnDelta, + paidTurnDelta, + freeTurnBalanceAfter, + paidTurnBalanceAfter, + AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME, + "ASK_CHAT_MESSAGE", + refId, + idempotencyKey + ); + } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } + + private PageRequest latestFirstPage() { + return PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java index a8428f0b..89273be7 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java @@ -13,8 +13,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; +import java.util.Comparator; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -102,6 +106,100 @@ void save_token_usage() { assertThat(found.getThinkingTokens()).isEqualTo(30L); } + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + ReportGenerationLog matchingLog = startLog( + matchingUser, + ReportGenerationType.MONTHLY_V2, + 301L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM + ); + reportGenerationLogRepository.save(matchingLog); + reportGenerationLogRepository.save( + startLog( + sameNicknameUser, + ReportGenerationType.MONTHLY_V2, + 302L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM + ) + ); + reportGenerationLogRepository.save( + startLog( + otherUser, + ReportGenerationType.DAILY, + 303L, + ReportGenerationStep.DAILY_GENERATE + ) + ); + + em.flush(); + em.clear(); + + // when + Page logs = reportGenerationLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + PageRequest.of(0, 20) + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(ReportGenerationLog::getReportId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + } + + @Test + void find_all_for_admin_keeps_null_user_logs_and_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + ReportGenerationLog first = reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.DAILY, 401L, ReportGenerationStep.DAILY_GENERATE) + ); + ReportGenerationLog second = reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.WEEKLY, 402L, ReportGenerationStep.WEEKLY_GENERATE) + ); + ReportGenerationLog orphan = reportGenerationLogRepository.save( + startLog(null, ReportGenerationType.TYPE, 403L, ReportGenerationStep.TYPE_SELECTION) + ); + + em.flush(); + em.clear(); + + // when + Page logs = reportGenerationLogRepository.findAllForAdmin( + null, + null, + PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ) + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(3); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(ReportGenerationLog::getCreatedAt) + .thenComparing(ReportGenerationLog::getId) + .reversed() + ); + assertThat(logs.getContent()) + .extracting(ReportGenerationLog::getId) + .contains(first.getId(), second.getId(), orphan.getId()); + assertThat(logs.getContent()) + .filteredOn(log -> log.getId().equals(orphan.getId())) + .singleElement() + .satisfies(log -> assertThat(log.getUser()).isNull()); + } + private ReportGenerationLog startLog( User user, ReportGenerationType reportType, @@ -117,4 +215,11 @@ private ReportGenerationLog startLog( "GPT_4_O_MINI" ); } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java new file mode 100644 index 00000000..57245de4 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java @@ -0,0 +1,146 @@ +package com.devkor.ifive.nadab.domain.wallet; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Comparator; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class CrystalLogRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + CrystalLogRepository crystalLogRepository; + + @Autowired + TestEntityManager em; + + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + CrystalLog matchingLog = crystalLogRepository.save( + confirmedLog(matchingUser, -100L, 900L, "MONTHLY_REPORT", 301L) + ); + crystalLogRepository.save( + confirmedLog(sameNicknameUser, -200L, 800L, "MONTHLY_REPORT", 302L) + ); + crystalLogRepository.save( + confirmedLog(otherUser, 100L, 1000L, "DAILY_REWARD", 303L) + ); + + em.flush(); + em.clear(); + + // when + Page logs = crystalLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(CrystalLog::getRefId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + } + + @Test + void find_all_for_admin_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + CrystalLog first = crystalLogRepository.save( + confirmedLog(user, 100L, 100L, "DAILY_REWARD", 401L) + ); + CrystalLog second = crystalLogRepository.save( + crystalLog(user, -50L, 50L, CrystalLogStatus.PENDING, "MONTHLY_REPORT", 402L) + ); + + em.flush(); + em.clear(); + + // when + Page logs = crystalLogRepository.findAllForAdmin( + null, + null, + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(2); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(CrystalLog::getCreatedAt) + .thenComparing(CrystalLog::getId) + .reversed() + ); + assertThat(logs.getContent()).extracting(CrystalLog::getId) + .containsExactly(second.getId(), first.getId()); + } + + private CrystalLog confirmedLog(User user, long delta, long balanceAfter, String refType, Long refId) { + return crystalLog(user, delta, balanceAfter, CrystalLogStatus.CONFIRMED, refType, refId); + } + + private CrystalLog crystalLog( + User user, + long delta, + long balanceAfter, + CrystalLogStatus status, + String refType, + Long refId + ) { + if (status == CrystalLogStatus.PENDING) { + return CrystalLog.createPending( + user, + delta, + balanceAfter, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + refType, + refId + ); + } + return CrystalLog.createConfirmed( + user, + delta, + balanceAfter, + CrystalLogReason.DAILY_ANSWER_REWARD, + refType, + refId + ); + } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } + + private PageRequest latestFirstPage() { + return PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ); + } +}