diff --git a/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java b/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java index 3c574e13..0bacbdeb 100644 --- a/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java +++ b/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java @@ -7,32 +7,24 @@ import com.linglevel.api.fcm.service.FcmMessagingService; import com.linglevel.api.i18n.CountryCode; import com.linglevel.api.i18n.LanguageCode; -import com.linglevel.api.streak.entity.DailyCompletion; import com.linglevel.api.streak.entity.FreezeTransaction; import com.linglevel.api.streak.entity.StreakReminderMessage; 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 lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; -import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; -/** - * 스트릭 보호 알림 스케줄러 - * 매일 밤 9시에 실행하여 스트릭이 깨지지 않도록 알림을 전송합니다. - * - 조건: currentStreak > 0 && 오늘 학습 미완료 - */ +/** 스트릭 보호 알림 스케줄러 매일 밤 9시에 실행하여 스트릭이 깨지지 않도록 알림을 전송합니다. - 조건: currentStreak > 0 && 오늘 학습 미완료 */ @Component @RequiredArgsConstructor @Slf4j @@ -50,15 +42,14 @@ public class StreakProtectionScheduler { private static final String CAMPAIGN_ID = "streak_protection"; private static final int BATCH_SIZE = 500; - /** - * 매일 밤 9시에 실행: 스트릭 보호 알림 전송 - */ + /** 매일 밤 9시에 실행: 스트릭 보호 알림 전송 */ @Scheduled(cron = "0 0 21 * * *", zone = "Asia/Seoul") public void sendStreakProtectionNotifications() { Instant startTime = Instant.now(); LocalDate today = LocalDate.now(KST); - log.info("[Streak Protection] Starting notification batch at 21:00 KST for date: {}", today); + log.info( + "[Streak Protection] Starting notification batch at 21:00 KST for date: {}", today); int candidateUsers = 0; int usersWithoutCompletion = 0; @@ -68,7 +59,8 @@ public void sendStreakProtectionNotifications() { try { // 1. 현재 스트릭이 있는 모든 활성 사용자 조회 (currentStreak > 0) - List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); + List activeUsers = + userStudyReportRepository.findByCurrentStreakGreaterThan(0); candidateUsers = activeUsers.size(); log.info("[Streak Protection] Found {} active users with streak > 0", candidateUsers); @@ -78,8 +70,8 @@ public void sendStreakProtectionNotifications() { String userId = report.getUserId(); // 2-1. 오늘 학습 완료 여부 확인 - boolean hasCompletedToday = dailyCompletionRepository - .existsByUserIdAndCompletionDate(userId, today); + boolean hasCompletedToday = + dailyCompletionRepository.existsByUserIdAndCompletionDate(userId, today); if (hasCompletedToday) { continue; // 이미 학습 완료한 사용자는 스킵 } @@ -93,9 +85,8 @@ public void sendStreakProtectionNotifications() { } usersWithTokens++; - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); + List fcmTokens = + tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); // 2-3. 언어 결정 LanguageCode languageCode = determineLanguageFromTokens(tokens); @@ -104,30 +95,38 @@ public void sendStreakProtectionNotifications() { boolean usedFreezeYesterday = checkIfFreezeUsedYesterday(userId, today); // 2-5. 메시지 타입 결정 (프리즈 사용 여부에 따라) - StreakReminderMessage messageType = usedFreezeYesterday - ? StreakReminderMessage.STREAK_SAVED_BY_FREEZE - : StreakReminderMessage.STREAK_PROTECTION; + StreakReminderMessage messageType = + usedFreezeYesterday + ? StreakReminderMessage.STREAK_SAVED_BY_FREEZE + : StreakReminderMessage.STREAK_PROTECTION; StreakReminderMessage.Message message = messageType.getRandomMessage(languageCode); + String title = String.format(message.getTitle(), report.getCurrentStreak()); String body = String.format(message.getBodyFormat(), report.getCurrentStreak()); - FcmMessageRequest messageRequest = FcmMessageRequest.builder() - .title(message.getTitle()) - .body(body) - .type(NOTIFICATION_TYPE) - .campaignId(CAMPAIGN_ID) - .action("open_app") - .build(); + FcmMessageRequest messageRequest = + FcmMessageRequest.builder() + .title(title) + .body(body) + .type(NOTIFICATION_TYPE) + .campaignId(CAMPAIGN_ID) + .action("open_app") + .build(); // 2-5. 알림 전송 try { if (fcmTokens.size() == 1) { fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); notificationsSent++; - log.debug("[Streak Protection] Sent to user: {} (streak: {}, lang: {}, type: {})", - userId, report.getCurrentStreak(), languageCode, messageType); + log.debug( + "[Streak Protection] Sent to user: {} (streak: {}, lang: {}, type: {})", + userId, + report.getCurrentStreak(), + languageCode, + messageType); } else { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); + BatchResponse response = + fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); for (int i = 0; i < response.getResponses().size(); i++) { if (response.getResponses().get(i).isSuccessful()) { @@ -135,52 +134,66 @@ public void sendStreakProtectionNotifications() { } else { notificationsFailed++; String failedToken = fcmTokens.get(i); - log.warn("[Streak Protection] Failed to send to user: {}, token error: {}", - userId, response.getResponses().get(i).getException().getMessage()); + log.warn( + "[Streak Protection] Failed to send to user: {}, token error: {}", + userId, + response.getResponses().get(i).getException().getMessage()); fcmTokenService.deactivateToken(failedToken); } } - log.debug("[Streak Protection] Multicast to user: {} - Success: {}, Failed: {}", - userId, response.getSuccessCount(), response.getFailureCount()); + log.debug( + "[Streak Protection] Multicast to user: {} - Success: {}, Failed: {}", + userId, + response.getSuccessCount(), + response.getFailureCount()); } } catch (Exception e) { notificationsFailed++; - log.warn("[Streak Protection] Failed to send notification to user: {}", userId, e); + log.warn( + "[Streak Protection] Failed to send notification to user: {}", + userId, + e); } } long durationMillis = Duration.between(startTime, Instant.now()).toMillis(); - log.info("[Streak Protection] Completed. Candidates: {}, Without completion: {}, With tokens: {}, " + - "Sent: {}, Failed: {}, Duration: {}ms", - candidateUsers, usersWithoutCompletion, usersWithTokens, - notificationsSent, notificationsFailed, durationMillis); + log.info( + "[Streak Protection] Completed. Candidates: {}, Without completion: {}, With tokens: {}, " + + "Sent: {}, Failed: {}, Duration: {}ms", + candidateUsers, + usersWithoutCompletion, + usersWithTokens, + notificationsSent, + notificationsFailed, + durationMillis); } catch (Exception e) { - log.error("[Streak Protection] Critical error. Candidates: {}, Without completion: {}, Sent: {}, Failed: {}", - candidateUsers, usersWithoutCompletion, notificationsSent, notificationsFailed, e); + log.error( + "[Streak Protection] Critical error. Candidates: {}, Without completion: {}, Sent: {}, Failed: {}", + candidateUsers, + usersWithoutCompletion, + notificationsSent, + notificationsFailed, + e); } } - /** - * 어제 프리즈가 사용되었는지 확인합니다. - * 어제 날짜(00:00 ~ 23:59)에 amount가 -1인 트랜잭션이 있으면 프리즈 사용됨 - */ + /** 어제 프리즈가 사용되었는지 확인합니다. 어제 날짜(00:00 ~ 23:59)에 amount가 -1인 트랜잭션이 있으면 프리즈 사용됨 */ private boolean checkIfFreezeUsedYesterday(String userId, LocalDate today) { LocalDate yesterday = today.minusDays(1); Instant yesterdayStart = yesterday.atStartOfDay(KST).toInstant(); Instant yesterdayEnd = today.atStartOfDay(KST).toInstant(); - List transactions = freezeTransactionRepository - .findByUserIdAndAmountAndCreatedAtBetween(userId, -1, yesterdayStart, yesterdayEnd); + List transactions = + freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( + userId, -1, yesterdayStart, yesterdayEnd); return !transactions.isEmpty(); } - /** - * FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. - */ + /** FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. */ private LanguageCode determineLanguageFromTokens(List tokens) { if (tokens.isEmpty()) { return LanguageCode.EN; @@ -190,9 +203,7 @@ private LanguageCode determineLanguageFromTokens(List tokens) { return convertCountryCodeToLanguageCode(countryCode); } - /** - * CountryCode를 LanguageCode로 변환합니다. - */ + /** CountryCode를 LanguageCode로 변환합니다. */ private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { if (countryCode == null) { return LanguageCode.EN; diff --git a/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java b/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java index 1f46d0eb..f1f6679f 100644 --- a/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java +++ b/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java @@ -1,5 +1,9 @@ package com.linglevel.api.streak.scheduler; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + import com.google.firebase.messaging.BatchResponse; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.SendResponse; @@ -9,10 +13,13 @@ import com.linglevel.api.fcm.service.FcmMessagingService; import com.linglevel.api.fcm.service.FcmTokenService; import com.linglevel.api.i18n.CountryCode; -import com.linglevel.api.i18n.LanguageCode; import com.linglevel.api.streak.entity.UserStudyReport; import com.linglevel.api.streak.repository.DailyCompletionRepository; import com.linglevel.api.streak.repository.UserStudyReportRepository; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,39 +29,25 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) @DisplayName("스트릭 보호 스케줄러 테스트") class StreakProtectionSchedulerTest { - @Mock - private UserStudyReportRepository userStudyReportRepository; + @Mock private UserStudyReportRepository userStudyReportRepository; - @Mock - private DailyCompletionRepository dailyCompletionRepository; + @Mock private DailyCompletionRepository dailyCompletionRepository; @Mock - private com.linglevel.api.streak.repository.FreezeTransactionRepository freezeTransactionRepository; + private com.linglevel.api.streak.repository.FreezeTransactionRepository + freezeTransactionRepository; - @Mock - private FcmTokenRepository fcmTokenRepository; + @Mock private FcmTokenRepository fcmTokenRepository; - @Mock - private FcmMessagingService fcmMessagingService; + @Mock private FcmMessagingService fcmMessagingService; - @Mock - private FcmTokenService fcmTokenService; + @Mock private FcmTokenService fcmTokenService; - @InjectMocks - private StreakProtectionScheduler scheduler; + @InjectMocks private StreakProtectionScheduler scheduler; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private LocalDate today; @@ -69,8 +62,7 @@ void setUp() { void sendNotification_ToActiveUserWithoutCompletion() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); // 오늘 학습 미완료 when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) @@ -78,13 +70,12 @@ void sendNotification_ToActiveUserWithoutCompletion() throws Exception { // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); // FCM 토큰 있음 FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // when scheduler.sendStreakProtectionNotifications(); @@ -98,8 +89,7 @@ void sendNotification_ToActiveUserWithoutCompletion() throws Exception { void noNotification_WhenAlreadyCompleted() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); // 오늘 학습 완료 when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) @@ -117,15 +107,13 @@ void noNotification_WhenAlreadyCompleted() throws Exception { void noNotification_WhenNoFcmToken() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // FCM 토큰 없음 - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of()); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of()); // when scheduler.sendStreakProtectionNotifications(); @@ -139,24 +127,22 @@ void noNotification_WhenNoFcmToken() throws Exception { void sendMulticast_WhenMultipleTokens() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); // 여러 FCM 토큰 - List tokens = List.of( - createFcmToken("user1", "token1", CountryCode.KR), - createFcmToken("user1", "token2", CountryCode.KR) - ); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(tokens); + List tokens = + List.of( + createFcmToken("user1", "token1", CountryCode.KR), + createFcmToken("user1", "token2", CountryCode.KR)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); BatchResponse batchResponse = mock(BatchResponse.class); when(batchResponse.getSuccessCount()).thenReturn(2); @@ -187,37 +173,38 @@ void sendMulticast_WhenMultipleTokens() throws Exception { void languageConversion_Korean() throws Exception { // given UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> - request.getTitle().contains("불꽃") || - request.getTitle().contains("스트릭") || - request.getTitle().contains("마지막") || - request.getTitle().contains("늦지") || - request.getTitle().contains("자기") || - request.getTitle().contains("남았") || - request.getTitle().contains("기다려") || - request.getTitle().contains("기회") || - request.getTitle().contains("거의") || - request.getTitle().contains("오늘") - )); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat( + request -> + request.getTitle().contains("불꽃") + || request.getTitle().contains("스트릭") + || request.getTitle().contains("마지막") + || request.getTitle().contains("늦지") + || request.getTitle().contains("자기") + || request.getTitle().contains("남았") + || request.getTitle().contains("기다려") + || request.getTitle().contains("기회") + || request.getTitle().contains("거의") + || request.getTitle().contains("오늘"))); } @Test @@ -225,36 +212,37 @@ void languageConversion_Korean() throws Exception { void languageConversion_English() throws Exception { // given UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); FcmToken token = createFcmToken("user1", "token1", CountryCode.US); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> - request.getTitle().contains("flame") || - request.getTitle().contains("streak") || - request.getTitle().contains("chance") || - request.getTitle().contains("late") || - request.getTitle().contains("minutes") || - request.getTitle().contains("left") || - request.getTitle().contains("waiting") || - request.getTitle().contains("Almost") || - request.getTitle().contains("Only") - )); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat( + request -> + request.getTitle().contains("flame") + || request.getTitle().contains("streak") + || request.getTitle().contains("chance") + || request.getTitle().contains("late") + || request.getTitle().contains("minutes") + || request.getTitle().contains("left") + || request.getTitle().contains("waiting") + || request.getTitle().contains("Almost") + || request.getTitle().contains("Only"))); } @Test @@ -262,36 +250,37 @@ void languageConversion_English() throws Exception { void languageConversion_Japanese() throws Exception { // given UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); FcmToken token = createFcmToken("user1", "token1", CountryCode.JP); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> - request.getTitle().contains("炎") || - request.getTitle().contains("ストリーク") || - request.getTitle().contains("チャンス") || - request.getTitle().contains("遅く") || - request.getTitle().contains("寝る前") || - request.getTitle().contains("残って") || - request.getTitle().contains("待って") || - request.getTitle().contains("もうすぐ") || - request.getTitle().contains("今日") - )); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat( + request -> + request.getTitle().contains("炎") + || request.getTitle().contains("ストリーク") + || request.getTitle().contains("チャンス") + || request.getTitle().contains("遅く") + || request.getTitle().contains("寝る前") + || request.getTitle().contains("残って") + || request.getTitle().contains("待って") + || request.getTitle().contains("もうすぐ") + || request.getTitle().contains("今日"))); } @Test @@ -299,41 +288,39 @@ void languageConversion_Japanese() throws Exception { void messageContainsStreakCount() throws Exception { // given UserStudyReport user = createUserReport("user1", 7); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> - request.getBody().contains("7") - )); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat(request -> (request.getTitle() + request.getBody()).contains("7"))); } @Test @DisplayName("여러 사용자에게 개별 알림 전송") void sendToMultipleUsers() throws Exception { // given - List users = List.of( - createUserReport("user1", 3), - createUserReport("user2", 5), - createUserReport("user3", 7) - ); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(users); + List users = + List.of( + createUserReport("user1", 3), + createUserReport("user2", 5), + createUserReport("user3", 7)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(users); // 모두 학습 미완료 when(dailyCompletionRepository.existsByUserIdAndCompletionDate(anyString(), eq(today))) @@ -341,7 +328,7 @@ void sendToMultipleUsers() throws Exception { // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - anyString(), eq(-1), any(), any())) + anyString(), eq(-1), any(), any())) .thenReturn(List.of()); // 각각 FCM 토큰 있음 @@ -356,7 +343,8 @@ void sendToMultipleUsers() throws Exception { scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService, times(3)).sendMessage(anyString(), any(FcmMessageRequest.class)); + verify(fcmMessagingService, times(3)) + .sendMessage(anyString(), any(FcmMessageRequest.class)); } @Test @@ -364,15 +352,13 @@ void sendToMultipleUsers() throws Exception { void sendFreezeMessage_WhenFreezeUsedYesterday() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // 어제 프리즈 사용 (amount = -1인 트랜잭션 존재) com.linglevel.api.streak.entity.FreezeTransaction freezeTransaction = @@ -383,19 +369,26 @@ void sendFreezeMessage_WhenFreezeUsedYesterday() throws Exception { .createdAt(today.minusDays(1).atStartOfDay(KST).toInstant()) .build(); when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of(freezeTransaction)); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> { - // STREAK_SAVED_BY_FREEZE 메시지는 프리즈 관련 키워드를 포함 - String title = request.getTitle(); - String body = request.getBody(); - return body.contains("프리즈") && (body.contains("꼭") || body.contains("반드시") || body.contains("학습")); - })); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat( + request -> { + // STREAK_SAVED_BY_FREEZE 메시지는 프리즈 관련 키워드를 포함 + String title = request.getTitle(); + String body = request.getBody(); + return body.contains("프리즈") + && (body.contains("꼭") + || body.contains("반드시") + || body.contains("학습")); + })); } @Test @@ -403,35 +396,43 @@ void sendFreezeMessage_WhenFreezeUsedYesterday() throws Exception { void sendProtectionMessage_WhenNoFreezeUsed() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); // 어제 프리즈 사용 안함 (트랜잭션 없음) when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); // when scheduler.sendStreakProtectionNotifications(); // then - verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> { - // STREAK_PROTECTION 메시지는 "자기 전", "5분", "늦지", "기회" 등의 키워드를 포함하거나 - // 스트릭 보호 관련 메시지 (단, 프리즈 언급은 없음) - String title = request.getTitle(); - String body = request.getBody(); - return (title.contains("자기") || title.contains("남았") || title.contains("기다려") || - title.contains("늦지") || title.contains("기회") || title.contains("불꽃") || - title.contains("거의") || title.contains("마무리") || title.contains("스트릭")) && - (!body.contains("프리즈")); // 프리즈 언급 없음 - })); + verify(fcmMessagingService) + .sendMessage( + eq("token1"), + argThat( + request -> { + // STREAK_PROTECTION 메시지는 "자기 전", "5분", "늦지", "기회" 등의 키워드를 포함하거나 + // 스트릭 보호 관련 메시지 (단, 프리즈 언급은 없음) + String title = request.getTitle(); + String body = request.getBody(); + return (title.contains("자기") + || title.contains("남았") + || title.contains("기다려") + || title.contains("늦지") + || title.contains("기회") + || title.contains("불꽃") + || title.contains("거의") + || title.contains("마무리") + || title.contains("스트릭")) + && (!body.contains("프리즈")); // 프리즈 언급 없음 + })); } @Test @@ -439,33 +440,30 @@ void sendProtectionMessage_WhenNoFreezeUsed() throws Exception { void checkFreezeUsage_YesterdayDateRange() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(token)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); // when scheduler.sendStreakProtectionNotifications(); // then - 정확한 시간 범위로 조회했는지 검증 - ArgumentCaptor startCaptor = ArgumentCaptor.forClass(java.time.Instant.class); - ArgumentCaptor endCaptor = ArgumentCaptor.forClass(java.time.Instant.class); + ArgumentCaptor startCaptor = + ArgumentCaptor.forClass(java.time.Instant.class); + ArgumentCaptor endCaptor = + ArgumentCaptor.forClass(java.time.Instant.class); - verify(freezeTransactionRepository).findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), - eq(-1), - startCaptor.capture(), - endCaptor.capture() - ); + verify(freezeTransactionRepository) + .findByUserIdAndAmountAndCreatedAtBetween( + eq("user1"), eq(-1), startCaptor.capture(), endCaptor.capture()); // 어제 00:00 ~ 오늘 00:00 범위 확인 java.time.LocalDate yesterday = today.minusDays(1); @@ -481,23 +479,21 @@ void checkFreezeUsage_YesterdayDateRange() throws Exception { void deactivateFailedTokens() throws Exception { // given UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)) - .thenReturn(List.of(user)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) .thenReturn(false); // 프리즈 사용 안함 when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) + eq("user1"), eq(-1), any(), any())) .thenReturn(List.of()); - List tokens = List.of( - createFcmToken("user1", "token1", CountryCode.KR), - createFcmToken("user1", "token2", CountryCode.KR) - ); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(tokens); + List tokens = + List.of( + createFcmToken("user1", "token1", CountryCode.KR), + createFcmToken("user1", "token2", CountryCode.KR)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); // 하나는 성공, 하나는 실패 BatchResponse batchResponse = mock(BatchResponse.class);