diff --git a/build.gradle b/build.gradle index 5a62707a..041a5562 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ springBoot { } group = 'com.linglevel' -version = '3.1.3-SNAPSHOT' +version = '3.1.4-SNAPSHOT' java { toolchain { diff --git a/src/main/java/com/linglevel/api/admin/controller/AdminController.java b/src/main/java/com/linglevel/api/admin/controller/AdminController.java index e3bc453c..12c04b2c 100644 --- a/src/main/java/com/linglevel/api/admin/controller/AdminController.java +++ b/src/main/java/com/linglevel/api/admin/controller/AdminController.java @@ -8,6 +8,8 @@ import com.linglevel.api.admin.dto.NotificationBroadcastResponse; import com.linglevel.api.admin.dto.NotificationSendResponse; import com.linglevel.api.admin.dto.NotificationSendRequest; +import com.linglevel.api.admin.dto.RecoverStreakRequest; +import com.linglevel.api.admin.dto.RecoverStreakResponse; import com.linglevel.api.admin.dto.ResetTodayStreakRequest; import com.linglevel.api.fcm.dto.FcmMessageRequest; import com.linglevel.api.admin.dto.UpdateChunkRequest; @@ -24,6 +26,8 @@ import com.linglevel.api.content.article.dto.ArticleOriginResponse; import com.linglevel.api.content.article.dto.GetArticleOriginsRequest; import com.linglevel.api.content.article.service.ArticleService; +import com.linglevel.api.streak.entity.UserStudyReport; +import com.linglevel.api.streak.service.StreakService; import com.linglevel.api.user.entity.User; import com.linglevel.api.user.repository.UserRepository; import com.linglevel.api.user.ticket.service.TicketService; @@ -57,6 +61,7 @@ public class AdminController { private final TicketService ticketService; private final UserRepository userRepository; private final ArticleService articleService; + private final StreakService streakService; @Operation(summary = "책 청크 수정", description = "어드민 권한으로 특정 책의 청크 내용을 수정합니다.") @PutMapping("/books/{bookId}/chapters/{chapterId}/chunks/{chunkId}") @@ -199,6 +204,40 @@ public ResponseEntity resetTodayStreak(@Valid @RequestBody Rese return ResponseEntity.ok(new MessageResponse("User " + request.getUserId() + "'s streak status for today has been reset.")); } + @Operation(summary = "스트릭 복구", description = "어드민 권한으로 특정 사용자의 누락된 스트릭을 복구합니다. MISSED 날짜는 COMPLETED로 변경하고, FREEZE_USED는 COMPLETED로 변경하며 프리즈를 보상합니다. 복구 범위 이후 날짜들도 프리즈를 사용하여 최대한 연결합니다.") + @PostMapping("/streaks/recover") + public ResponseEntity recoverStreak( + @Parameter(description = "스트릭 복구 요청", required = true) @Valid @RequestBody RecoverStreakRequest request) { + + log.info("Admin recovering streak - userId: {}, startDate: {}, endDate: {}", + request.getUserId(), request.getStartDate(), request.getEndDate()); + + // 사용자 존재 여부 확인 + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, "User not found.")); + + // 스트릭 복구 실행 + streakService.recoverStreak(request.getUserId(), request.getStartDate(), request.getEndDate()); + + // 복구 후 UserStudyReport 조회 + UserStudyReport updatedReport = streakService.recalculateUserStudyReport(request.getUserId()); + + RecoverStreakResponse response = RecoverStreakResponse.builder() + .message("Streak recovered successfully.") + .userId(request.getUserId()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .currentStreak(updatedReport.getCurrentStreak()) + .longestStreak(updatedReport.getLongestStreak()) + .lastCompletionDate(updatedReport.getLastCompletionDate()) + .build(); + + log.info("Streak recovery completed for user {} - currentStreak: {}, longestStreak: {}", + request.getUserId(), updatedReport.getCurrentStreak(), updatedReport.getLongestStreak()); + + return ResponseEntity.ok(response); + } + @ExceptionHandler(TicketException.class) public ResponseEntity handleTicketException(TicketException e) { log.error("Admin Ticket Exception: {}", e.getMessage()); diff --git a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java new file mode 100644 index 00000000..86024d01 --- /dev/null +++ b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java @@ -0,0 +1,31 @@ +package com.linglevel.api.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "스트릭 복구 요청") +public class RecoverStreakRequest { + + @NotBlank(message = "userId는 필수입니다.") + @Schema(description = "사용자 ID", example = "user123", required = true) + private String userId; + + @NotNull(message = "startDate는 필수입니다.") + @Schema(description = "복구 시작 날짜 (YYYY-MM-DD)", example = "2025-01-10", required = true) + private LocalDate startDate; + + @NotNull(message = "endDate는 필수입니다.") + @Schema(description = "복구 종료 날짜 (YYYY-MM-DD)", example = "2025-01-15", required = true) + private LocalDate endDate; +} diff --git a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java new file mode 100644 index 00000000..f268e9c7 --- /dev/null +++ b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java @@ -0,0 +1,38 @@ +package com.linglevel.api.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "스트릭 복구 응답") +public class RecoverStreakResponse { + + @Schema(description = "응답 메시지", example = "Streak recovered successfully.") + private String message; + + @Schema(description = "사용자 ID", example = "user123") + private String userId; + + @Schema(description = "복구 시작 날짜", example = "2025-01-10") + private LocalDate startDate; + + @Schema(description = "복구 종료 날짜", example = "2025-01-15") + private LocalDate endDate; + + @Schema(description = "복구 후 현재 스트릭", example = "15") + private Integer currentStreak; + + @Schema(description = "복구 후 최장 스트릭", example = "20") + private Integer longestStreak; + + @Schema(description = "마지막 완료일", example = "2025-01-15") + private LocalDate lastCompletionDate; +} diff --git a/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java b/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java index afe8bde4..6d773e1e 100644 --- a/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java +++ b/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java @@ -29,4 +29,9 @@ public interface DailyCompletionRepository extends MongoRepository findByUserIdAndCompletionDateAfter(String userId, LocalDate startDate); + + /** + * 특정 사용자의 모든 DailyCompletion을 날짜 오름차순으로 조회 + */ + List findByUserIdOrderByCompletionDateAsc(String userId); } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/service/StreakService.java b/src/main/java/com/linglevel/api/streak/service/StreakService.java index 74c041a1..298ae9ae 100644 --- a/src/main/java/com/linglevel/api/streak/service/StreakService.java +++ b/src/main/java/com/linglevel/api/streak/service/StreakService.java @@ -822,6 +822,320 @@ private static class CalendarDayInfo { RewardInfo rewards; RewardInfo expectedRewards; } + + @Transactional + public UserStudyReport recalculateUserStudyReport(String userId) { + LocalDate today = getKstToday(); + + // UserStudyReport 가져오기 (없으면 새로 생성) + UserStudyReport report = userStudyReportRepository.findByUserId(userId) + .orElseGet(() -> createNewUserStudyReport(userId)); + + // 모든 DailyCompletion 가져오기 (날짜 순으로 정렬) + List allCompletions = dailyCompletionRepository + .findByUserIdOrderByCompletionDateAsc(userId); + + if (allCompletions.isEmpty()) { + // 완료 기록이 없으면 모든 값 초기화 + report.setCurrentStreak(0); + report.setLongestStreak(0); + report.setLastCompletionDate(null); + report.setStreakStartDate(null); + report.setUpdatedAt(Instant.now()); + return userStudyReportRepository.save(report); + } + + // 스트릭 재계산 + int currentStreak = 0; + int longestStreak = 0; + LocalDate streakStartDate = null; + LocalDate lastCompletionDate = null; + LocalDate previousDate = null; + + for (DailyCompletion completion : allCompletions) { + LocalDate date = completion.getCompletionDate(); + StreakStatus status = completion.getStreakStatus(); + + // 미래 날짜나 상태가 없는 경우 스킵 + if (date.isAfter(today) || status == null) { + continue; + } + + if (status == StreakStatus.COMPLETED) { + if (previousDate == null) { + // 첫 완료일 + currentStreak = 1; + streakStartDate = date; + } else { + long daysBetween = ChronoUnit.DAYS.between(previousDate, date); + + if (daysBetween == 1) { + // 연속 완료 + currentStreak++; + } else { + // 연속성 끊김 - 새로운 스트릭 시작 + currentStreak = 1; + streakStartDate = date; + } + } + + lastCompletionDate = date; + previousDate = date; + + // 최장 스트릭 갱신 + if (currentStreak > longestStreak) { + longestStreak = currentStreak; + } + } else if (status == StreakStatus.FREEZE_USED) { + // 프리즈 사용 - 스트릭 유지, 카운트 증가 없음 + previousDate = date; + } else if (status == StreakStatus.MISSED) { + // 놓침 - 스트릭 끊김 + currentStreak = 0; + streakStartDate = null; + previousDate = null; + } + } + + // 오늘 날짜와의 연속성 확인 + if (lastCompletionDate != null && !lastCompletionDate.equals(today)) { + long daysSinceLastCompletion = ChronoUnit.DAYS.between(lastCompletionDate, today); + if (daysSinceLastCompletion > 1) { + // 오늘까지의 연속성이 끊김 - 스트릭 리셋 + currentStreak = 0; + streakStartDate = null; + } + } + + // UserStudyReport 업데이트 + report.setCurrentStreak(currentStreak); + report.setLongestStreak(longestStreak); + report.setLastCompletionDate(lastCompletionDate); + report.setStreakStartDate(streakStartDate); + report.setUpdatedAt(Instant.now()); + + log.info("Recalculated UserStudyReport for user {}. Current streak: {}, Longest streak: {}", + userId, currentStreak, longestStreak); + + return userStudyReportRepository.save(report); + } + + @Transactional + public void recoverStreak(String userId, LocalDate startDate, LocalDate endDate) { + LocalDate today = getKstToday(); + + // 복구 범위 검증 + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("startDate must be before or equal to endDate"); + } + if (endDate.isAfter(today)) { + endDate = today; + } + + // 1. 시작 전날 확인 (streakCount 기준 계산) + LocalDate dayBeforeStart = startDate.minusDays(1); + int baseStreakCount = dailyCompletionRepository + .findByUserIdAndCompletionDate(userId, dayBeforeStart) + .map(DailyCompletion::getStreakCount) + .orElse(0); + + // 2. 복구 처리 및 프리즈 보상 수집 + List completionsToSave = new ArrayList<>(); + List freezeTransactions = new ArrayList<>(); + int currentStreakCount = baseStreakCount; + int earnedFreezes = 0; + + // 복구 범위 처리 + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional existingOpt = dailyCompletionRepository + .findByUserIdAndCompletionDate(userId, date); + + if (existingOpt.isPresent()) { + DailyCompletion existing = existingOpt.get(); + + if (existing.getStreakStatus() == StreakStatus.COMPLETED) { + // 이미 완료됨 - 그대로 유지 + currentStreakCount++; + } else if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { + // FREEZE_USED → COMPLETED로 변경 + 프리즈 보상 + existing.setStreakStatus(StreakStatus.COMPLETED); + currentStreakCount++; + existing.setStreakCount(currentStreakCount); + completionsToSave.add(existing); + + // 프리즈 보상 + FreezeTransaction rewardTx = FreezeTransaction.builder() + .userId(userId) + .amount(1) + .description("Streak recovery compensation for " + date) + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(rewardTx); + earnedFreezes++; + + log.info("Recovered FREEZE_USED to COMPLETED for user {} on {}. Rewarded 1 freeze.", + userId, date); + } else { + // MISSED 또는 기타 → COMPLETED로 변경 + existing.setStreakStatus(StreakStatus.COMPLETED); + currentStreakCount++; + existing.setStreakCount(currentStreakCount); + completionsToSave.add(existing); + } + } else { + // 레코드 없음 (MISSED) → 새로 생성 + currentStreakCount++; + DailyCompletion newCompletion = DailyCompletion.builder() + .userId(userId) + .completionDate(date) + .streakStatus(StreakStatus.COMPLETED) + .streakCount(currentStreakCount) + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + completionsToSave.add(newCompletion); + + log.info("Created new COMPLETED record for user {} on {}. StreakCount: {}", + userId, date, currentStreakCount); + } + } + + // 3. 복구 범위 이후 날짜들 처리 (프리즈 자동 사용) + LocalDate yesterday = today.minusDays(1); + LocalDate currentDate = endDate.plusDays(1); + int availableFreezes = earnedFreezes; + + while (!currentDate.isAfter(yesterday)) { + Optional existingOpt = dailyCompletionRepository + .findByUserIdAndCompletionDate(userId, currentDate); + + if (existingOpt.isPresent()) { + DailyCompletion existing = existingOpt.get(); + + if (existing.getStreakStatus() == StreakStatus.COMPLETED) { + // 완료됨 - 스트릭 계속 증가 + currentStreakCount++; + existing.setStreakCount(currentStreakCount); + completionsToSave.add(existing); + currentDate = currentDate.plusDays(1); + } else if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { + // 이미 프리즈 사용됨 - 스트릭 유지 + existing.setStreakCount(currentStreakCount); + completionsToSave.add(existing); + currentDate = currentDate.plusDays(1); + } else { + // MISSED - 프리즈로 커버 시도 + if (availableFreezes > 0) { + existing.setStreakStatus(StreakStatus.FREEZE_USED); + existing.setStreakCount(currentStreakCount); + completionsToSave.add(existing); + + // 프리즈 사용 + FreezeTransaction usageTx = FreezeTransaction.builder() + .userId(userId) + .amount(-1) + .description("Auto-consumed for recovery on " + currentDate) + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(usageTx); + availableFreezes--; + + log.info("Auto-used freeze for user {} on {}. {} freezes remaining.", + userId, currentDate, availableFreezes); + + currentDate = currentDate.plusDays(1); + } else { + // 프리즈 없음 - 연결 중단 + log.info("No freeze available for user {} on {}. Stopping streak connection.", + userId, currentDate); + break; + } + } + } else { + // 레코드 없음 (MISSED) - 프리즈로 커버 시도 + if (availableFreezes > 0) { + DailyCompletion newFreezeCompletion = DailyCompletion.builder() + .userId(userId) + .completionDate(currentDate) + .streakStatus(StreakStatus.FREEZE_USED) + .streakCount(currentStreakCount) + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + completionsToSave.add(newFreezeCompletion); + + // 프리즈 사용 + FreezeTransaction usageTx = FreezeTransaction.builder() + .userId(userId) + .amount(-1) + .description("Auto-consumed for recovery on " + currentDate) + + + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(usageTx); + availableFreezes--; + + log.info("Auto-used freeze and created FREEZE_USED for user {} on {}. {} freezes remaining.", + userId, currentDate, availableFreezes); + + currentDate = currentDate.plusDays(1); + } else { + // 프리즈 없음 - 연결 중단 + log.info("No freeze available for user {} on {}. Stopping streak connection.", + userId, currentDate); + break; + } + } + } + + // 4. 오늘(today) 처리 - 프리즈 사용은 안 하지만, 학습했다면 streakCount 업데이트 + Optional todayCompletionOpt = dailyCompletionRepository + .findByUserIdAndCompletionDate(userId, today); + + if (todayCompletionOpt.isPresent()) { + DailyCompletion todayCompletion = todayCompletionOpt.get(); + + if (todayCompletion.getStreakStatus() == StreakStatus.COMPLETED) { + // 오늘 학습함 - streakCount 업데이트 + currentStreakCount++; + todayCompletion.setStreakCount(currentStreakCount); + completionsToSave.add(todayCompletion); + + log.info("Updated today's completion for user {} with streakCount: {}", userId, currentStreakCount); + } + // MISSED나 다른 상태면 배치에서 처리하므로 여기서는 스킵 + } + + // 5. 데이터 저장 + if (!completionsToSave.isEmpty()) { + dailyCompletionRepository.saveAll(completionsToSave); + log.info("Saved {} DailyCompletion records for user {}", completionsToSave.size(), userId); + } + + if (!freezeTransactions.isEmpty()) { + freezeTransactionRepository.saveAll(freezeTransactions); + log.info("Saved {} FreezeTransaction records for user {}", freezeTransactions.size(), userId); + } + + // 5. UserStudyReport 재계산 및 프리즈 반영 + UserStudyReport finalReport = recalculateUserStudyReport(userId); + + // 프리즈 개수 업데이트 + // availableFreezes는 복구 과정에서 남은 프리즈 개수 + // 최종 프리즈 = 기존 보유 + 남은 프리즈 (최대 MAX_FREEZE_COUNT) + int currentFreezes = finalReport.getAvailableFreezes() != null ? finalReport.getAvailableFreezes() : 0; + int usedFreezes = earnedFreezes - availableFreezes; + finalReport.setAvailableFreezes(Math.max(0, Math.min(MAX_FREEZE_COUNT, currentFreezes + availableFreezes))); + userStudyReportRepository.save(finalReport); + + log.info("Streak recovery completed for user {} from {} to {}. Earned {} freezes, used {} freezes. Final freezes: {}", + userId, startDate, endDate, earnedFreezes, usedFreezes, finalReport.getAvailableFreezes()); + } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index fbcd5a56..9b3f0c07 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -65,5 +65,5 @@ sentry.traces-sample-rate=1.0 sentry.environment=${spring.profiles.active} # Rate Limiting -rate.limit.capacity=200 +rate.limit.capacity=5000 rate.limit.refill.duration.minutes=1 \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java new file mode 100644 index 00000000..951738b7 --- /dev/null +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java @@ -0,0 +1,371 @@ +package com.linglevel.api.streak.service; + +import com.linglevel.api.streak.entity.DailyCompletion; +import com.linglevel.api.streak.entity.StreakStatus; +import com.linglevel.api.streak.entity.UserStudyReport; +import com.linglevel.api.streak.repository.DailyCompletionRepository; +import com.linglevel.api.streak.repository.FreezeTransactionRepository; +import com.linglevel.api.streak.repository.UserStudyReportRepository; +import com.linglevel.api.user.ticket.service.TicketService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StreakService - recalculateUserStudyReport 테스트") +class StreakServiceRecalculateTest { + + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @InjectMocks + private StreakService streakService; + + private static final String TEST_USER_ID = "test-user-123"; + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + private LocalDate today; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setAvailableFreezes(0); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + } + + @Test + @DisplayName("완료 기록이 없으면 모든 값이 초기화된다") + void recalculate_NoCompletions_ResetsAllValues() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(List.of()); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(0); + assertThat(result.getLongestStreak()).isEqualTo(0); + assertThat(result.getLastCompletionDate()).isNull(); + assertThat(result.getStreakStartDate()).isNull(); + verify(userStudyReportRepository).save(testReport); + } + + @Test + @DisplayName("연속 3일 완료 시 currentStreak=3, longestStreak=3") + void recalculate_ThreeConsecutiveDays_CalculatesCorrectly() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); + LocalDate day3 = today; + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), + createCompletion(day3, StreakStatus.COMPLETED, 3) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(3); + assertThat(result.getLongestStreak()).isEqualTo(3); + assertThat(result.getLastCompletionDate()).isEqualTo(day3); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("프리즈 사용으로 스트릭이 유지된 경우") + void recalculate_WithFreezeUsed_MaintainsStreak() { + // given + LocalDate day1 = today.minusDays(3); + LocalDate day2 = today.minusDays(2); // FREEZE_USED + LocalDate day3 = today.minusDays(1); + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.FREEZE_USED, 1), + createCompletion(day3, StreakStatus.COMPLETED, 2) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day3); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("MISSED 상태로 스트릭이 끊긴 후 다시 시작") + void recalculate_WithMissed_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); + LocalDate day3 = today.minusDays(3); // MISSED + LocalDate day4 = today.minusDays(2); + LocalDate day5 = today.minusDays(1); + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), + createCompletion(day3, StreakStatus.MISSED, null), + createCompletion(day4, StreakStatus.COMPLETED, 1), + createCompletion(day5, StreakStatus.COMPLETED, 2) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); // 첫 번째 스트릭도 2였으므로 최장은 2 + assertThat(result.getLastCompletionDate()).isEqualTo(day5); + assertThat(result.getStreakStartDate()).isEqualTo(day4); + } + + @Test + @DisplayName("최장 스트릭이 현재 스트릭보다 길었던 경우") + void recalculate_LongestStreakInPast_KeepsLongestStreak() { + // given + LocalDate day1 = today.minusDays(6); + LocalDate day2 = today.minusDays(5); + LocalDate day3 = today.minusDays(4); + LocalDate day4 = today.minusDays(3); + LocalDate day5 = today.minusDays(2); // MISSED + LocalDate day6 = today.minusDays(1); + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), + createCompletion(day3, StreakStatus.COMPLETED, 3), + createCompletion(day4, StreakStatus.COMPLETED, 4), + createCompletion(day5, StreakStatus.MISSED, null), + createCompletion(day6, StreakStatus.COMPLETED, 1) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(1); + assertThat(result.getLongestStreak()).isEqualTo(4); // 과거 최장 기록 + assertThat(result.getLastCompletionDate()).isEqualTo(day6); + assertThat(result.getStreakStartDate()).isEqualTo(day6); + } + + @Test + @DisplayName("연속성이 끊긴 경우 (날짜 간격이 2일 이상)") + void recalculate_GapInDates_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); + // day3(today.minusDays(3))에 기록 없음 - 연속성 끊김 + LocalDate day4 = today.minusDays(2); + LocalDate day5 = today.minusDays(1); + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), + createCompletion(day4, StreakStatus.COMPLETED, 1), + createCompletion(day5, StreakStatus.COMPLETED, 2) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day5); + assertThat(result.getStreakStartDate()).isEqualTo(day4); + } + + @Test + @DisplayName("마지막 완료일이 어제이고 오늘 기록이 없으면 currentStreak 유지") + void recalculate_LastCompletionYesterday_MaintainsStreak() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); + // 오늘은 아직 완료 안 함 + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day2); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("마지막 완료일이 2일 전이면 currentStreak=0으로 리셋") + void recalculate_LastCompletionTwoDaysAgo_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(3); + LocalDate day2 = today.minusDays(2); + // 어제와 오늘 기록 없음 + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(0); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day2); + assertThat(result.getStreakStartDate()).isNull(); + } + + @Test + @DisplayName("복잡한 시나리오: 프리즈 + MISSED + 여러 스트릭 구간") + void recalculate_ComplexScenario_CalculatesCorrectly() { + // given + LocalDate day1 = today.minusDays(10); + LocalDate day2 = today.minusDays(9); + LocalDate day3 = today.minusDays(8); + LocalDate day4 = today.minusDays(7); // FREEZE_USED + LocalDate day5 = today.minusDays(6); + LocalDate day6 = today.minusDays(5); // MISSED - 스트릭 끊김 + LocalDate day7 = today.minusDays(4); + LocalDate day8 = today.minusDays(3); + LocalDate day9 = today.minusDays(2); + LocalDate day10 = today.minusDays(1); + LocalDate day11 = today; + + List completions = List.of( + createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), + createCompletion(day3, StreakStatus.COMPLETED, 3), + createCompletion(day4, StreakStatus.FREEZE_USED, 3), + createCompletion(day5, StreakStatus.COMPLETED, 4), + createCompletion(day6, StreakStatus.MISSED, null), + createCompletion(day7, StreakStatus.COMPLETED, 1), + createCompletion(day8, StreakStatus.COMPLETED, 2), + createCompletion(day9, StreakStatus.COMPLETED, 3), + createCompletion(day10, StreakStatus.COMPLETED, 4), + createCompletion(day11, StreakStatus.COMPLETED, 5) + ); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(5); // day7~day11 + assertThat(result.getLongestStreak()).isEqualTo(5); // 현재 스트릭이 가장 김 + assertThat(result.getLastCompletionDate()).isEqualTo(day11); + assertThat(result.getStreakStartDate()).isEqualTo(day7); + } + + private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { + return DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .streakStatus(status) + .streakCount(streakCount) + .firstCompletionCount(0) + .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + } +} diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java new file mode 100644 index 00000000..d2daaf3b --- /dev/null +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java @@ -0,0 +1,539 @@ +package com.linglevel.api.streak.service; + +import com.linglevel.api.streak.entity.DailyCompletion; +import com.linglevel.api.streak.entity.FreezeTransaction; +import com.linglevel.api.streak.entity.StreakStatus; +import com.linglevel.api.streak.entity.UserStudyReport; +import com.linglevel.api.streak.repository.DailyCompletionRepository; +import com.linglevel.api.streak.repository.FreezeTransactionRepository; +import com.linglevel.api.streak.repository.UserStudyReportRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StreakService - recoverStreak TDD 테스트") +class StreakServiceRecoveryTest { + + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @Mock + private FreezeTransactionRepository freezeTransactionRepository; + + @InjectMocks + private StreakService streakService; + + @Captor + private ArgumentCaptor> dailyCompletionListCaptor; + + @Captor + private ArgumentCaptor> freezeTransactionListCaptor; + + @Captor + private ArgumentCaptor userStudyReportCaptor; + + private static final String TEST_USER_ID = "test-user-123"; + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + private LocalDate today; + private Map completionMap; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setAvailableFreezes(0); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + + completionMap = new HashMap<>(); + } + + @Test + @DisplayName("시나리오 1: 단순 MISSED 1일 복구") + void recoverStreak_SimpleMissedDay_CreatesCompletion() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); // MISSED → 복구 대상 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + completionMap.put(day1, day1Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + // 저장된 모든 DailyCompletion 수집 + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2가 COMPLETED로 생성되었는지 확인 + DailyCompletion day2Completion = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 completion not found")); + + assertThat(day2Completion.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Completion.getStreakCount()).isEqualTo(2); + assertThat(day2Completion.getUserId()).isEqualTo(TEST_USER_ID); + + // 프리즈 트랜잭션이 없어야 함 (복구만 했고, FREEZE_USED 아님) + verify(freezeTransactionRepository, never()).saveAll(any()); + + // UserStudyReport가 재계산되어 저장되었는지 확인 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(2); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + assertThat(savedReport.getLastCompletionDate()).isEqualTo(day2); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 보상 없음 + } + + @Test + @DisplayName("시나리오 2: FREEZE_USED를 COMPLETED로 복구 + 프리즈 보상") + void recoverStreak_FreezeUsedDay_ConvertsToCompletedAndRewardsFreeze() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); // FREEZE_USED → 복구 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + // 저장된 모든 DailyCompletion 수집 + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2가 COMPLETED로 변경되었는지 확인 + DailyCompletion day2Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 completion not found")); + + assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Updated.getStreakCount()).isEqualTo(2); + + // 프리즈 보상 확인 - 1개의 +1 트랜잭션 + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + + List allFreezes = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allFreezes::addAll); + + // +1 보상 트랜잭션이 정확히 1개 있어야 함 + List rewards = allFreezes.stream() + .filter(tx -> tx.getAmount() == 1) + .toList(); + assertThat(rewards).hasSize(1); + + FreezeTransaction rewardTx = rewards.get(0); + assertThat(rewardTx.getUserId()).isEqualTo(TEST_USER_ID); + assertThat(rewardTx.getDescription()).contains(day2.toString()); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(2); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + // 프리즈: day2 복구 보상 +1 (오늘은 배치에서 처리하므로 제외) + assertThat(savedReport.getAvailableFreezes()).isEqualTo(1); + } + + @Test + @DisplayName("시나리오 3: 연속 MISSED 3일 복구") + void recoverStreak_MultipleMissedDays_CreatesAllCompletions() { + // given + LocalDate day1 = today.minusDays(4); + LocalDate day2 = today.minusDays(3); // MISSED → 복구 + LocalDate day3 = today.minusDays(2); // MISSED → 복구 + LocalDate day4 = today.minusDays(1); // MISSED → 복구 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + completionMap.put(day1, day1Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day4); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2, day3, day4가 모두 COMPLETED로 생성되었는지 확인 + DailyCompletion day2Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + DailyCompletion day3Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day3)) + .findFirst() + .orElseThrow(() -> new AssertionError("day3 not found")); + + DailyCompletion day4Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElseThrow(() -> new AssertionError("day4 not found")); + + // streakCount 검증 + assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Saved.getStreakCount()).isEqualTo(2); + + assertThat(day3Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day3Saved.getStreakCount()).isEqualTo(3); + + assertThat(day4Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day4Saved.getStreakCount()).isEqualTo(4); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(4); + assertThat(savedReport.getLongestStreak()).isEqualTo(4); + } + + @Test + @DisplayName("시나리오 4: 복구 후 이후 날짜 재계산 - 프리즈 자동 사용") + void recoverStreak_AfterRecovery_AutoUsesFreeze() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) + LocalDate day3 = today.minusDays(3); // MISSED (복구 범위 밖, 프리즈 자동 사용) + LocalDate day4 = today.minusDays(2); // COMPLETED (연결됨) + LocalDate day5 = today.minusDays(1); // COMPLETED (연결됨) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); + DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 2); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + completionMap.put(day4, day4Completion); + completionMap.put(day5, day5Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2: FREEZE_USED → COMPLETED (streakCount=2) + DailyCompletion day2Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Updated.getStreakCount()).isEqualTo(2); + + // day3: 프리즈 자동 사용으로 FREEZE_USED 생성 (streakCount=2 유지) + DailyCompletion day3Created = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day3)) + .findFirst() + .orElseThrow(() -> new AssertionError("day3 not found")); + + assertThat(day3Created.getStreakStatus()).isEqualTo(StreakStatus.FREEZE_USED); + assertThat(day3Created.getStreakCount()).isEqualTo(2); + + // day4: streakCount 재계산 (3으로 증가) + DailyCompletion day4Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElseThrow(() -> new AssertionError("day4 not found")); + + assertThat(day4Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day4Updated.getStreakCount()).isEqualTo(3); + + // day5: streakCount 재계산 (4로 증가) + DailyCompletion day5Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day5)) + .findFirst() + .orElseThrow(() -> new AssertionError("day5 not found")); + + assertThat(day5Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day5Updated.getStreakCount()).isEqualTo(4); + + // 프리즈 트랜잭션 확인: +1 (day2 복구) -1 (day3 자동 사용) + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + + List allTxs = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); + + // +1 보상이 정확히 1개, -1 사용이 정확히 1개 + long rewardCount = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); + long usageCount = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); + + assertThat(rewardCount).isEqualTo(1); + assertThat(usageCount).isEqualTo(1); + + FreezeTransaction rewardTx = allTxs.stream() + .filter(tx -> tx.getAmount() == 1) + .findFirst() + .orElseThrow(() -> new AssertionError("Reward transaction not found")); + assertThat(rewardTx.getDescription()).contains(day2.toString()); + + FreezeTransaction usageTx = allTxs.stream() + .filter(tx -> tx.getAmount() == -1) + .findFirst() + .orElseThrow(() -> new AssertionError("Usage transaction not found")); + assertThat(usageTx.getDescription()).contains(day3.toString()); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(4); + assertThat(savedReport.getLongestStreak()).isEqualTo(4); + assertThat(savedReport.getLastCompletionDate()).isEqualTo(day5); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 + } + + @Test + @DisplayName("시나리오 5: 복구 후 프리즈 부족으로 스트릭 연결 중단") + void recoverStreak_AfterRecovery_StopsWhenNoFreeze() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); // MISSED → 복구 (프리즈 보상 없음) + LocalDate day3 = today.minusDays(3); // MISSED (프리즈 없어서 연결 안 됨) + LocalDate day4 = today.minusDays(2); // COMPLETED (연결 안 됨, 새 스트릭) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); + + completionMap.put(day1, day1Completion); + completionMap.put(day4, day4Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2는 복구됨 + DailyCompletion day2Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Saved.getStreakCount()).isEqualTo(2); + + // day3은 프리즈가 없어서 FREEZE_USED로 생성되지 않음 + boolean hasDay3 = allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day3)); + + assertThat(hasDay3).isFalse(); + + // day4는 기존 값 유지 (연결 안 됨) + DailyCompletion day4Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElse(null); + + // day4가 저장되지 않았거나, 저장되었어도 streakCount가 1로 유지 + if (day4Saved != null) { + assertThat(day4Saved.getStreakCount()).isEqualTo(1); + } + + // UserStudyReport 검증 - day3에서 끊겼으므로 currentStreak=0 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + // day2까지만 연결, day3에서 끊김 -> 오늘까지 2일 이상 차이 -> streak=0 + assertThat(savedReport.getCurrentStreak()).isEqualTo(0); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + } + + @Test + @DisplayName("시나리오 6: 여러 FREEZE_USED 복구 + 복잡한 프리즈 사용") + void recoverStreak_MultipleFreeze_ComplexScenario() { + // given + LocalDate day1 = today.minusDays(7); // COMPLETED (streak=1) + LocalDate day2 = today.minusDays(6); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) + LocalDate day3 = today.minusDays(5); // MISSED → COMPLETED (복구) + LocalDate day4 = today.minusDays(4); // MISSED (프리즈 1개 사용) + LocalDate day5 = today.minusDays(3); // COMPLETED (연결됨) + LocalDate day6 = today.minusDays(2); // COMPLETED (연결됨) + LocalDate day7 = today.minusDays(1); // COMPLETED (연결됨) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 1); + DailyCompletion day6Completion = createCompletion(day6, StreakStatus.COMPLETED, 2); + DailyCompletion day7Completion = createCompletion(day7, StreakStatus.COMPLETED, 3); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + completionMap.put(day5, day5Completion); + completionMap.put(day6, day6Completion); + completionMap.put(day7, day7Completion); + + setupMocks(); + + // when - day2, day3 복구 + streakService.recoverStreak(TEST_USER_ID, day2, day3); + + // then + List allSaved = new ArrayList<>(); + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2: FREEZE_USED → COMPLETED (streak=2) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day2) + && dc.getStreakStatus() == StreakStatus.COMPLETED + && dc.getStreakCount() == 2)) + .isTrue(); + + // day3: MISSED → COMPLETED (streak=3) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day3) + && dc.getStreakStatus() == StreakStatus.COMPLETED + && dc.getStreakCount() == 3)) + .isTrue(); + + // day4: 프리즈 사용으로 FREEZE_USED (streak=3 유지) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day4) + && dc.getStreakStatus() == StreakStatus.FREEZE_USED + && dc.getStreakCount() == 3)) + .isTrue(); + + // day5, day6, day7: streakCount 재계산 + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day5) + && dc.getStreakCount() == 4)) + .isTrue(); + + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day6) + && dc.getStreakCount() == 5)) + .isTrue(); + + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day7) + && dc.getStreakCount() == 6)) + .isTrue(); + + // 프리즈 트랜잭션: +1 (day2 보상), -1 (day4 사용) + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + List allTxs = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); + + long rewards = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); + long usages = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); + + assertThat(rewards).isEqualTo(1); + assertThat(usages).isEqualTo(1); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(6); + assertThat(savedReport.getLongestStreak()).isEqualTo(6); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 + } + + private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { + return DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .streakStatus(status) + .streakCount(streakCount) + .firstCompletionCount(0) + .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + } + + private void setupMocks() { + // UserStudyReport mock + lenient().when(userStudyReportRepository.findByUserId(TEST_USER_ID)) + .thenReturn(Optional.of(testReport)); + lenient().when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // DailyCompletion 조회 mock + lenient().when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any(LocalDate.class))) + .thenAnswer(invocation -> { + LocalDate date = invocation.getArgument(1); + return Optional.ofNullable(completionMap.get(date)); + }); + + // DailyCompletion 전체 조회 mock (recalculateUserStudyReport용) - 날짜 순으로 정렬 + lenient().when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenAnswer(invocation -> { + List sorted = new ArrayList<>(completionMap.values()); + sorted.sort((a, b) -> a.getCompletionDate().compareTo(b.getCompletionDate())); + return sorted; + }); + + // saveAll mock + lenient().when(dailyCompletionRepository.saveAll(anyList())) + .thenAnswer(invocation -> { + List saved = invocation.getArgument(0); + // completionMap 업데이트 + saved.forEach(dc -> completionMap.put(dc.getCompletionDate(), dc)); + return saved; + }); + + lenient().when(freezeTransactionRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } +}