From 8a251afa950b73d78c679cdcdea571dedd4974cf Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 1 Jul 2026 16:07:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=9E=AC=EA=B0=80=EC=9E=85=20=ED=97=88=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C/=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 재가입 막힘 수정 - V30이 uq_provider_provider_id_deleted를 추가했지만 V4의 옛 제약 uq_provider_provider_id가 DROP되지 않아 두 제약이 공존했다. 탈퇴 유저 재로그인 시 익명화 UPDATE보다 신규 유저 INSERT가 먼저 flush되면서 옛 제약에 걸려 재가입이 500으로 실패했다. - V44로 옛 제약을 제거해 정책(재가입 = 신규 계정)대로 동작하게 한다. ## 하드 삭제 시 achievement 고아 데이터 정리 - 탈퇴 90일 경과 유저 하드 삭제 시 user_badges/user_missions/user_levels/ weekly_retro_streaks/daily_access_streaks가 남아 고아가 되었다(FK CASCADE 없음). - AchievementDeletionPort/Adapter를 추가해 유저 스코프 achievement 5종을 userId로 함께 삭제한다. ## 스케줄러 트랜잭션 격리 개선 - Cleanup/Mission/Notification 스케줄러가 배치 전체를 단일 트랜잭션으로 처리해, 한 건 실패 시 배치 전체가 롤백되고 나머지도 처리되지 않았다. - 건별 @Transactional 헬퍼 빈(CleanupExecutor/MissionFailureProcessor/ ReminderNotificationSender)으로 분리하고, 스케줄러는 건별 runCatching으로 격리하여 한 건 실패가 나머지 처리를 막지 않도록 한다. ## 타임존 일원화 - NotificationScheduler의 인라인 ZoneId.of("Asia/Seoul")를 ServiceTime.ZONE로 통일한다. cron은 앱 규칙(UTC 발화 + ServiceTime 변환)에 따라 그대로 둔다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integration/scheduler/CleanupExecutor.kt | 58 ++++++++++ .../integration/scheduler/CleanupScheduler.kt | 61 ++++------- .../scheduler/MissionFailureProcessor.kt | 46 ++++++++ .../integration/scheduler/MissionScheduler.kt | 37 ++----- .../scheduler/NotificationScheduler.kt | 33 +----- .../scheduler/ReminderNotificationSender.kt | 33 ++++++ .../achievement/AchievementDeletionAdapter.kt | 27 +++++ .../provided/AchievementDeletionPort.kt | 7 ++ .../required/DailyAccessStreakRepository.kt | 2 + .../required/UserBadgeRepository.kt | 2 + .../required/UserLevelRepository.kt | 2 + .../required/UserMissionRepository.kt | 2 + .../required/WeeklyRetroStreakRepository.kt | 2 + ...44__drop_legacy_user_unique_constraint.sql | 1 + .../scheduler/CleanupExecutorTest.kt | 82 ++++++++++++++ .../scheduler/CleanupSchedulerTest.kt | 101 +++++------------- .../scheduler/MissionFailureProcessorTest.kt | 66 ++++++++++++ .../scheduler/MissionSchedulerTest.kt | 97 ++++++----------- .../scheduler/NotificationSchedulerTest.kt | 32 +++--- .../ReminderNotificationSenderTest.kt | 38 +++++++ 20 files changed, 473 insertions(+), 256 deletions(-) create mode 100644 src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutor.kt create mode 100644 src/main/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessor.kt create mode 100644 src/main/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSender.kt create mode 100644 src/main/kotlin/com/didit/adapter/persistence/achievement/AchievementDeletionAdapter.kt create mode 100644 src/main/kotlin/com/didit/application/achievement/provided/AchievementDeletionPort.kt create mode 100644 src/main/resources/db/migration/V44__drop_legacy_user_unique_constraint.sql create mode 100644 src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutorTest.kt create mode 100644 src/test/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessorTest.kt create mode 100644 src/test/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSenderTest.kt diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutor.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutor.kt new file mode 100644 index 00000000..99f6c8ae --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutor.kt @@ -0,0 +1,58 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.provided.AchievementDeletionPort +import com.didit.application.auth.required.RefreshTokenRepository +import com.didit.application.auth.required.UserRepository +import com.didit.application.notification.provided.NotificationDeletionPort +import com.didit.application.organization.provided.OrganizationDeletionPort +import com.didit.application.retrospect.provided.RetrospectDeletionPort +import com.didit.application.retrospect.required.RetrospectiveRepository +import jakarta.transaction.Transactional +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.util.UUID + +@Component +class CleanupExecutor( + private val retrospectiveRepository: RetrospectiveRepository, + private val refreshTokenRepository: RefreshTokenRepository, + private val userRepository: UserRepository, + private val projectDeletionPort: OrganizationDeletionPort, + private val notificationDeletionPort: NotificationDeletionPort, + private val retrospectDeletionPort: RetrospectDeletionPort, + private val achievementDeletionPort: AchievementDeletionPort, +) { + @Transactional + fun cleanPendingRetrospects(): Int { + val cutoff = LocalDateTime.now().minusDays(1) + val targets = retrospectiveRepository.findAllPendingBefore(cutoff) + targets.forEach { retrospectiveRepository.delete(it) } + return targets.size + } + + @Transactional + fun cleanExpiredRefreshTokens(): Int = refreshTokenRepository.deleteAllExpiredBefore(LocalDateTime.now()) + + fun findWithdrawnToAnonymize(): List = + userRepository.findAllWithdrawnAndNotAnonymizedBefore(LocalDateTime.now().minusDays(30)).map { it.id } + + fun findWithdrawnToDelete(): List = + userRepository.findAllWithdrawnAndAnonymizedBefore(LocalDateTime.now().minusDays(90)).map { it.id } + + @Transactional + fun anonymize(userId: UUID) { + val user = userRepository.findById(userId) ?: return + user.anonymize() + userRepository.save(user) + } + + @Transactional + fun deleteUser(userId: UUID) { + val user = userRepository.findById(userId) ?: return + projectDeletionPort.deleteByUserId(userId) + notificationDeletionPort.deleteByUserId(userId) + retrospectDeletionPort.deleteByUserId(userId) + achievementDeletionPort.deleteByUserId(userId) + userRepository.delete(user) + } +} diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupScheduler.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupScheduler.kt index 115d41c3..7b057673 100644 --- a/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupScheduler.kt +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/CleanupScheduler.kt @@ -1,32 +1,18 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.auth.required.RefreshTokenRepository -import com.didit.application.auth.required.UserRepository -import com.didit.application.notification.provided.NotificationDeletionPort -import com.didit.application.organization.provided.OrganizationDeletionPort -import com.didit.application.retrospect.provided.RetrospectDeletionPort -import com.didit.application.retrospect.required.RetrospectiveRepository -import jakarta.transaction.Transactional import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -import java.time.LocalDateTime @Component class CleanupScheduler( - private val retrospectiveRepository: RetrospectiveRepository, - private val refreshTokenRepository: RefreshTokenRepository, - private val userRepository: UserRepository, - private val projectDeletionPort: OrganizationDeletionPort, - private val notificationDeletionPort: NotificationDeletionPort, - private val retrospectDeletionPort: RetrospectDeletionPort, + private val cleanupExecutor: CleanupExecutor, ) { companion object { private val logger = LoggerFactory.getLogger(CleanupScheduler::class.java) } @Scheduled(cron = "0 0 5 * * *") - @Transactional fun cleanup() { cleanPendingRetrospects() cleanExpiredRefreshTokens() @@ -34,19 +20,15 @@ class CleanupScheduler( } private fun cleanPendingRetrospects() { - val cutoff = LocalDateTime.now().minusDays(1) - - val targets = retrospectiveRepository.findAllPendingBefore(cutoff) - - targets.forEach { retrospectiveRepository.delete(it) } - - logger.info("PENDING 회고 삭제 - count: ${targets.size}") + runCatching { cleanupExecutor.cleanPendingRetrospects() } + .onSuccess { logger.info("PENDING 회고 삭제 - count: $it") } + .onFailure { logger.error("PENDING 회고 삭제 실패", it) } } private fun cleanExpiredRefreshTokens() { - val count = refreshTokenRepository.deleteAllExpiredBefore(LocalDateTime.now()) - - logger.info("만료 리프레시 토큰 삭제 - count: $count") + runCatching { cleanupExecutor.cleanExpiredRefreshTokens() } + .onSuccess { logger.info("만료 리프레시 토큰 삭제 - count: $it") } + .onFailure { logger.error("만료 리프레시 토큰 삭제 실패", it) } } private fun cleanWithdrawnUsers() { @@ -55,27 +37,22 @@ class CleanupScheduler( } private fun anonymizeWithdrawnUsers() { - val cutoff = LocalDateTime.now().minusDays(30) - val targets = userRepository.findAllWithdrawnAndNotAnonymizedBefore(cutoff) - - targets.forEach { user -> - user.anonymize() - userRepository.save(user) + var count = 0 + cleanupExecutor.findWithdrawnToAnonymize().forEach { userId -> + runCatching { cleanupExecutor.anonymize(userId) } + .onSuccess { count++ } + .onFailure { logger.error("탈퇴 유저 익명화 실패 - userId: $userId", it) } } - - logger.info("탈퇴 유저 익명화 - count: ${targets.size}") + logger.info("탈퇴 유저 익명화 - count: $count") } private fun deleteWithdrawnUsers() { - val cutoff = LocalDateTime.now().minusDays(90) - val targets = userRepository.findAllWithdrawnAndAnonymizedBefore(cutoff) - - targets.forEach { user -> - projectDeletionPort.deleteByUserId(user.id) - notificationDeletionPort.deleteByUserId(user.id) - retrospectDeletionPort.deleteByUserId(user.id) - userRepository.delete(user) + var count = 0 + cleanupExecutor.findWithdrawnToDelete().forEach { userId -> + runCatching { cleanupExecutor.deleteUser(userId) } + .onSuccess { count++ } + .onFailure { logger.error("탈퇴 유저 삭제 실패 - userId: $userId", it) } } - logger.info("탈퇴 유저 완전 삭제 - count: ${targets.size}") + logger.info("탈퇴 유저 완전 삭제 - count: $count") } } diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessor.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessor.kt new file mode 100644 index 00000000..dc32cbd7 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessor.kt @@ -0,0 +1,46 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.achievement.UserMission +import com.didit.domain.shared.ServiceTime +import jakarta.transaction.Transactional +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.temporal.TemporalAdjusters + +@Component +class MissionFailureProcessor( + private val userMissionRepository: UserMissionRepository, + private val retrospectiveRepository: RetrospectiveRepository, +) { + companion object { + private val logger = LoggerFactory.getLogger(MissionFailureProcessor::class.java) + } + + @Transactional + fun failExpiredLv2(userMission: UserMission) { + userMission.setFailureWaitingConfirm() + userMissionRepository.save(userMission) + } + + @Transactional + fun failIfNoRetroLastWeek(userMission: UserMission) { + val lastWeekStart = + ServiceTime.today().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(1) + + val lastWeekRetroCount = + retrospectiveRepository.countCompletedByUserIdAndPeriod( + userId = userMission.userId, + from = ServiceTime.startOfDayUtc(lastWeekStart), + to = ServiceTime.startOfDayUtc(lastWeekStart.plusWeeks(1)), + ) + + if (lastWeekRetroCount == 0) { + userMission.setFailureWaitingConfirm() + userMissionRepository.save(userMission) + logger.info("연속주차 실패 - userId: ${userMission.userId}, missionId: ${userMission.missionId}") + } + } +} diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt index 5f78223b..b2647d6c 100644 --- a/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt @@ -1,61 +1,40 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.achievement.required.MissionRepository import com.didit.application.achievement.required.UserMissionRepository -import com.didit.application.retrospect.required.RetrospectiveRepository -import com.didit.domain.shared.ServiceTime -import jakarta.transaction.Transactional import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -import java.time.temporal.TemporalAdjusters @Component class MissionScheduler( private val userMissionRepository: UserMissionRepository, - private val missionRepository: MissionRepository, - private val retrospectiveRepository: RetrospectiveRepository, + private val missionFailureProcessor: MissionFailureProcessor, ) { companion object { private val logger = LoggerFactory.getLogger(MissionScheduler::class.java) } @Scheduled(cron = "0 0 * * * *") - @Transactional fun checkExpiredLv2Missions() { val expiredMissions = userMissionRepository.findExpiredLv2Missions() + var count = 0 expiredMissions.forEach { userMission -> - userMission.setFailureWaitingConfirm() - userMissionRepository.save(userMission) + runCatching { missionFailureProcessor.failExpiredLv2(userMission) } + .onSuccess { count++ } + .onFailure { logger.error("Lv2 만료 미션 처리 실패 - missionId: ${userMission.id}", it) } } - logger.info("Lv2 만료 미션 검사 완료 - count: ${expiredMissions.size}") + logger.info("Lv2 만료 미션 검사 완료 - count: $count") } @Scheduled(cron = "0 0 0 ? * MON") - @Transactional fun checkConsecutiveWeekFailure() { val consecutiveWeekMissions = userMissionRepository.findConsecutiveWeekMissionsInProgress() consecutiveWeekMissions.forEach { userMission -> - val lastWeekStart = - ServiceTime.today().with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)).minusWeeks(1) - - val lastWeekRetroCount = - retrospectiveRepository.countCompletedByUserIdAndPeriod( - userId = userMission.userId, - from = ServiceTime.startOfDayUtc(lastWeekStart), - to = ServiceTime.startOfDayUtc(lastWeekStart.plusWeeks(1)), - ) - - if (lastWeekRetroCount == 0) { - userMission.setFailureWaitingConfirm() - userMissionRepository.save(userMission) - logger.info( - "연속주차 실패 - userId: ${userMission.userId}, missionId: ${userMission.missionId}", - ) - } + runCatching { missionFailureProcessor.failIfNoRetroLastWeek(userMission) } + .onFailure { logger.error("연속주차 실패 검사 실패 - missionId: ${userMission.id}", it) } } logger.info("연속주차 실패 검사 완료 - total: ${consecutiveWeekMissions.size}") diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/NotificationScheduler.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/NotificationScheduler.kt index 8b47b2fa..0f7972eb 100644 --- a/src/main/kotlin/com/didit/adapter/integration/scheduler/NotificationScheduler.kt +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/NotificationScheduler.kt @@ -1,23 +1,16 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.notification.provided.NotificationHistoryRegister import com.didit.application.notification.provided.NotificationSettingFinder -import com.didit.application.notification.provided.UserPushSender -import com.didit.domain.notification.NotificationHistoryCreateRequest -import com.didit.domain.notification.NotificationType -import jakarta.transaction.Transactional +import com.didit.domain.shared.ServiceTime import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import java.time.LocalTime -import java.time.ZoneId -import java.util.UUID @Component class NotificationScheduler( private val notificationSettingFinder: NotificationSettingFinder, - private val notificationHistoryRegister: NotificationHistoryRegister, - private val userPushSender: UserPushSender, + private val reminderNotificationSender: ReminderNotificationSender, ) { companion object { private val logger = LoggerFactory.getLogger(NotificationScheduler::class.java) @@ -28,31 +21,15 @@ class NotificationScheduler( } @Scheduled(cron = "0 */10 * * * *") - @Transactional fun sendReminderNotifications() { - val now = LocalTime.now(ZoneId.of("Asia/Seoul")).withSecond(0).withNano(0) + val now = LocalTime.now(ServiceTime.ZONE).withSecond(0).withNano(0) val settings = notificationSettingFinder.findAllByReminderTime(now) logger.info("회고 알림 스케줄러 실행 - time: $now, 대상 유저 수: ${settings.size}") settings.forEach { setting -> - runCatching { - sendToUser(setting.userId) - }.onFailure { e -> - logger.error("회고 알림 전송 실패 - userId: ${setting.userId}", e) - } + runCatching { reminderNotificationSender.send(setting.userId) } + .onFailure { e -> logger.error("회고 알림 전송 실패 - userId: ${setting.userId}", e) } } } - - private fun sendToUser(userId: UUID) { - notificationHistoryRegister.save( - NotificationHistoryCreateRequest( - userId = userId, - type = NotificationType.DAILY_REMINDER, - title = DAILY_REMINDER_TITLE, - body = DAILY_REMINDER_BODY, - ), - ) - userPushSender.sendToUser(userId, DAILY_REMINDER_TITLE, DAILY_REMINDER_BODY, DAILY_REMINDER_LINK) - } } diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSender.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSender.kt new file mode 100644 index 00000000..3fe45153 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSender.kt @@ -0,0 +1,33 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.notification.provided.NotificationHistoryRegister +import com.didit.application.notification.provided.UserPushSender +import com.didit.domain.notification.NotificationHistoryCreateRequest +import com.didit.domain.notification.NotificationType +import jakarta.transaction.Transactional +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class ReminderNotificationSender( + private val notificationHistoryRegister: NotificationHistoryRegister, + private val userPushSender: UserPushSender, +) { + @Transactional + fun send(userId: UUID) { + notificationHistoryRegister.save( + NotificationHistoryCreateRequest( + userId = userId, + type = NotificationType.DAILY_REMINDER, + title = NotificationScheduler.DAILY_REMINDER_TITLE, + body = NotificationScheduler.DAILY_REMINDER_BODY, + ), + ) + userPushSender.sendToUser( + userId, + NotificationScheduler.DAILY_REMINDER_TITLE, + NotificationScheduler.DAILY_REMINDER_BODY, + NotificationScheduler.DAILY_REMINDER_LINK, + ) + } +} diff --git a/src/main/kotlin/com/didit/adapter/persistence/achievement/AchievementDeletionAdapter.kt b/src/main/kotlin/com/didit/adapter/persistence/achievement/AchievementDeletionAdapter.kt new file mode 100644 index 00000000..6f5293fc --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/persistence/achievement/AchievementDeletionAdapter.kt @@ -0,0 +1,27 @@ +package com.didit.adapter.persistence.achievement + +import com.didit.application.achievement.provided.AchievementDeletionPort +import com.didit.application.achievement.required.DailyAccessStreakRepository +import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.achievement.required.WeeklyRetroStreakRepository +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class AchievementDeletionAdapter( + private val userBadgeRepository: UserBadgeRepository, + private val userMissionRepository: UserMissionRepository, + private val userLevelRepository: UserLevelRepository, + private val weeklyRetroStreakRepository: WeeklyRetroStreakRepository, + private val dailyAccessStreakRepository: DailyAccessStreakRepository, +) : AchievementDeletionPort { + override fun deleteByUserId(userId: UUID) { + userBadgeRepository.deleteAllByUserId(userId) + userMissionRepository.deleteAllByUserId(userId) + userLevelRepository.deleteAllByUserId(userId) + weeklyRetroStreakRepository.deleteAllByUserId(userId) + dailyAccessStreakRepository.deleteAllByUserId(userId) + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/provided/AchievementDeletionPort.kt b/src/main/kotlin/com/didit/application/achievement/provided/AchievementDeletionPort.kt new file mode 100644 index 00000000..be2370de --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/provided/AchievementDeletionPort.kt @@ -0,0 +1,7 @@ +package com.didit.application.achievement.provided + +import java.util.UUID + +interface AchievementDeletionPort { + fun deleteByUserId(userId: UUID) +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt index 031c3886..1c99f374 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt @@ -8,4 +8,6 @@ interface DailyAccessStreakRepository : Repository { fun findByUserId(userId: UUID): DailyAccessStreak? fun save(streak: DailyAccessStreak): DailyAccessStreak + + fun deleteAllByUserId(userId: UUID) } diff --git a/src/main/kotlin/com/didit/application/achievement/required/UserBadgeRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/UserBadgeRepository.kt index 2dc4bf97..01e22fd5 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/UserBadgeRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/UserBadgeRepository.kt @@ -16,4 +16,6 @@ interface UserBadgeRepository : Repository { fun countByBadgeId(badgeId: UUID): Long fun findAllByBadgeId(badgeId: UUID): List + + fun deleteAllByUserId(userId: UUID) } diff --git a/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt index 669dc4dc..76c42c38 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt @@ -12,6 +12,8 @@ interface UserLevelRepository : Repository { fun existsByUserId(userId: UUID): Boolean + fun deleteAllByUserId(userId: UUID) + @Query( "SELECT ul.currentLevel AS level, COUNT(ul) AS count FROM UserLevel ul " + "GROUP BY ul.currentLevel ORDER BY ul.currentLevel", diff --git a/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt index 03816b1c..dd5e812a 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt @@ -35,6 +35,8 @@ interface UserMissionRepository : Repository { fun save(userMission: UserMission): UserMission + fun deleteAllByUserId(userId: UUID) + @Query("SELECT um FROM UserMission um WHERE um.userId = :userId ORDER BY um.createdAt DESC") fun findByUserId(userId: UUID): List diff --git a/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt index f2181413..497a76f4 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt @@ -8,4 +8,6 @@ interface WeeklyRetroStreakRepository : Repository { fun findByUserId(userId: UUID): WeeklyRetroStreak? fun save(streak: WeeklyRetroStreak): WeeklyRetroStreak + + fun deleteAllByUserId(userId: UUID) } diff --git a/src/main/resources/db/migration/V44__drop_legacy_user_unique_constraint.sql b/src/main/resources/db/migration/V44__drop_legacy_user_unique_constraint.sql new file mode 100644 index 00000000..2ce1ff59 --- /dev/null +++ b/src/main/resources/db/migration/V44__drop_legacy_user_unique_constraint.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP INDEX uq_provider_provider_id; diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutorTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutorTest.kt new file mode 100644 index 00000000..776b8714 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupExecutorTest.kt @@ -0,0 +1,82 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.provided.AchievementDeletionPort +import com.didit.application.auth.required.RefreshTokenRepository +import com.didit.application.auth.required.UserRepository +import com.didit.application.notification.provided.NotificationDeletionPort +import com.didit.application.organization.provided.OrganizationDeletionPort +import com.didit.application.retrospect.provided.RetrospectDeletionPort +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.support.UserFixture +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test + +@ExtendWith(MockitoExtension::class) +class CleanupExecutorTest { + @Mock + lateinit var retrospectiveRepository: RetrospectiveRepository + + @Mock + lateinit var refreshTokenRepository: RefreshTokenRepository + + @Mock + lateinit var userRepository: UserRepository + + @Mock + lateinit var projectDeletionPort: OrganizationDeletionPort + + @Mock + lateinit var notificationDeletionPort: NotificationDeletionPort + + @Mock + lateinit var retrospectDeletionPort: RetrospectDeletionPort + + @Mock + lateinit var achievementDeletionPort: AchievementDeletionPort + + @InjectMocks + lateinit var cleanupExecutor: CleanupExecutor + + @Test + fun `deleteUser - 관련 데이터가 순서대로 삭제된다`() { + val user = + UserFixture.create().apply { + withdraw() + anonymize() + } + whenever(userRepository.findById(user.id)).thenReturn(user) + + cleanupExecutor.deleteUser(user.id) + + val inOrder = + inOrder( + projectDeletionPort, + notificationDeletionPort, + retrospectDeletionPort, + achievementDeletionPort, + userRepository, + ) + + inOrder.verify(projectDeletionPort).deleteByUserId(user.id) + inOrder.verify(notificationDeletionPort).deleteByUserId(user.id) + inOrder.verify(retrospectDeletionPort).deleteByUserId(user.id) + inOrder.verify(achievementDeletionPort).deleteByUserId(user.id) + inOrder.verify(userRepository).delete(user) + } + + @Test + fun `anonymize - 유저를 익명화하고 저장한다`() { + val user = UserFixture.create().apply { withdraw() } + whenever(userRepository.findById(user.id)).thenReturn(user) + + cleanupExecutor.anonymize(user.id) + + verify(userRepository).save(user) + } +} diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupSchedulerTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupSchedulerTest.kt index 3c2a0abb..f5abed0c 100644 --- a/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupSchedulerTest.kt +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/CleanupSchedulerTest.kt @@ -1,19 +1,10 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.auth.required.RefreshTokenRepository -import com.didit.application.auth.required.UserRepository -import com.didit.application.notification.provided.NotificationDeletionPort -import com.didit.application.organization.provided.OrganizationDeletionPort -import com.didit.application.retrospect.provided.RetrospectDeletionPort -import com.didit.application.retrospect.required.RetrospectiveRepository -import com.didit.domain.retrospect.Retrospective -import com.didit.support.UserFixture import org.junit.jupiter.api.extension.ExtendWith import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.any -import org.mockito.kotlin.inOrder +import org.mockito.kotlin.doThrow import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.UUID @@ -22,93 +13,51 @@ import kotlin.test.Test @ExtendWith(MockitoExtension::class) class CleanupSchedulerTest { @Mock - lateinit var retrospectiveRepository: RetrospectiveRepository - - @Mock - lateinit var refreshTokenRepository: RefreshTokenRepository - - @Mock - lateinit var userRepository: UserRepository - - @Mock - lateinit var projectDeletionPort: OrganizationDeletionPort - - @Mock - lateinit var notificationDeletionPort: NotificationDeletionPort - - @Mock - lateinit var retrospectDeletionPort: RetrospectDeletionPort + lateinit var cleanupExecutor: CleanupExecutor @InjectMocks lateinit var cleanupScheduler: CleanupScheduler @Test - fun `PENDING 회고가 있으면 삭제된다`() { - val retro1 = Retrospective.create(UUID.randomUUID()) - val retro2 = Retrospective.create(UUID.randomUUID()) - whenever(retrospectiveRepository.findAllPendingBefore(any())).thenReturn(listOf(retro1, retro2)) - whenever(refreshTokenRepository.deleteAllExpiredBefore(any())).thenReturn(0) - whenever(userRepository.findAllWithdrawnAndNotAnonymizedBefore(any())).thenReturn(emptyList()) - whenever(userRepository.findAllWithdrawnAndAnonymizedBefore(any())).thenReturn(emptyList()) - - cleanupScheduler.cleanup() - - verify(retrospectiveRepository).delete(retro1) - verify(retrospectiveRepository).delete(retro2) - } - - @Test - fun `만료된 리프레시 토큰이 삭제된다`() { - whenever(retrospectiveRepository.findAllPendingBefore(any())).thenReturn(emptyList()) - whenever(refreshTokenRepository.deleteAllExpiredBefore(any())).thenReturn(3) - whenever(userRepository.findAllWithdrawnAndNotAnonymizedBefore(any())).thenReturn(emptyList()) - whenever(userRepository.findAllWithdrawnAndAnonymizedBefore(any())).thenReturn(emptyList()) + fun `cleanup - 각 정리 작업을 실행한다`() { + whenever(cleanupExecutor.findWithdrawnToAnonymize()).thenReturn(emptyList()) + whenever(cleanupExecutor.findWithdrawnToDelete()).thenReturn(emptyList()) cleanupScheduler.cleanup() - verify(refreshTokenRepository).deleteAllExpiredBefore(any()) + verify(cleanupExecutor).cleanPendingRetrospects() + verify(cleanupExecutor).cleanExpiredRefreshTokens() + verify(cleanupExecutor).findWithdrawnToAnonymize() + verify(cleanupExecutor).findWithdrawnToDelete() } @Test - fun `30일 지난 탈퇴 유저는 익명화된다`() { - val user = UserFixture.create().apply { withdraw() } + fun `deleteWithdrawnUsers - 한 유저 삭제가 실패해도 나머지 유저는 삭제된다`() { + val failing = UUID.randomUUID() + val succeeding = UUID.randomUUID() - whenever(retrospectiveRepository.findAllPendingBefore(any())).thenReturn(emptyList()) - whenever(refreshTokenRepository.deleteAllExpiredBefore(any())).thenReturn(0) - whenever(userRepository.findAllWithdrawnAndNotAnonymizedBefore(any())).thenReturn(listOf(user)) - whenever(userRepository.findAllWithdrawnAndAnonymizedBefore(any())).thenReturn(emptyList()) + whenever(cleanupExecutor.findWithdrawnToAnonymize()).thenReturn(emptyList()) + whenever(cleanupExecutor.findWithdrawnToDelete()).thenReturn(listOf(failing, succeeding)) + doThrow(RuntimeException("삭제 실패")).whenever(cleanupExecutor).deleteUser(failing) cleanupScheduler.cleanup() - verify(userRepository).save(user) + verify(cleanupExecutor).deleteUser(failing) + verify(cleanupExecutor).deleteUser(succeeding) } @Test - fun `90일 지난 탈퇴 유저의 관련 데이터가 순서대로 삭제된다`() { - val user = - UserFixture.create().apply { - withdraw() - anonymize() - } + fun `anonymizeWithdrawnUsers - 한 유저 익명화가 실패해도 나머지 유저는 익명화된다`() { + val failing = UUID.randomUUID() + val succeeding = UUID.randomUUID() - whenever(retrospectiveRepository.findAllPendingBefore(any())).thenReturn(emptyList()) - whenever(refreshTokenRepository.deleteAllExpiredBefore(any())).thenReturn(0) - whenever(userRepository.findAllWithdrawnAndNotAnonymizedBefore(any())).thenReturn(emptyList()) - whenever(userRepository.findAllWithdrawnAndAnonymizedBefore(any())).thenReturn(listOf(user)) + whenever(cleanupExecutor.findWithdrawnToAnonymize()).thenReturn(listOf(failing, succeeding)) + whenever(cleanupExecutor.findWithdrawnToDelete()).thenReturn(emptyList()) + doThrow(RuntimeException("익명화 실패")).whenever(cleanupExecutor).anonymize(failing) cleanupScheduler.cleanup() - val inOrder = - inOrder( - projectDeletionPort, - notificationDeletionPort, - retrospectDeletionPort, - userRepository, - ) - - inOrder.verify(projectDeletionPort).deleteByUserId(user.id) - inOrder.verify(notificationDeletionPort).deleteByUserId(user.id) - inOrder.verify(retrospectDeletionPort).deleteByUserId(user.id) - inOrder.verify(userRepository).delete(user) + verify(cleanupExecutor).anonymize(failing) + verify(cleanupExecutor).anonymize(succeeding) } } diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessorTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessorTest.kt new file mode 100644 index 00000000..abaa6da4 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionFailureProcessorTest.kt @@ -0,0 +1,66 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.UserMission +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 org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class MissionFailureProcessorTest { + @Mock + lateinit var userMissionRepository: UserMissionRepository + + @Mock + lateinit var retrospectiveRepository: RetrospectiveRepository + + @InjectMocks + lateinit var missionFailureProcessor: MissionFailureProcessor + + private fun mission() = + UserMission( + userId = UUID.randomUUID(), + missionId = UUID.randomUUID(), + status = MissionStatus.IN_PROGRESS, + ) + + @Test + fun `failExpiredLv2 - 실패 대기 상태로 저장한다`() { + val userMission = mission() + whenever(userMissionRepository.save(any())).thenReturn(userMission) + + missionFailureProcessor.failExpiredLv2(userMission) + + verify(userMissionRepository).save(userMission) + } + + @Test + fun `failIfNoRetroLastWeek - 지난주 회고가 없으면 실패 처리한다`() { + val userMission = mission() + whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(0) + whenever(userMissionRepository.save(any())).thenReturn(userMission) + + missionFailureProcessor.failIfNoRetroLastWeek(userMission) + + verify(userMissionRepository).save(userMission) + } + + @Test + fun `failIfNoRetroLastWeek - 지난주 회고가 있으면 실패 처리하지 않는다`() { + val userMission = mission() + whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(1) + + missionFailureProcessor.failIfNoRetroLastWeek(userMission) + + verify(userMissionRepository, never()).save(any()) + } +} diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt index 67517e51..67ba8303 100644 --- a/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt @@ -1,18 +1,16 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.achievement.required.MissionRepository import com.didit.application.achievement.required.UserMissionRepository -import com.didit.application.retrospect.required.RetrospectiveRepository import com.didit.domain.achievement.MissionStatus import com.didit.domain.achievement.UserMission 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 org.mockito.kotlin.any +import org.mockito.kotlin.doThrow import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.time.LocalDateTime import java.util.UUID @ExtendWith(MockitoExtension::class) @@ -21,42 +19,33 @@ class MissionSchedulerTest { lateinit var userMissionRepository: UserMissionRepository @Mock - lateinit var missionRepository: MissionRepository + lateinit var missionFailureProcessor: MissionFailureProcessor - @Mock - lateinit var retrospectiveRepository: RetrospectiveRepository - - private lateinit var missionScheduler: MissionScheduler + @InjectMocks + lateinit var missionScheduler: MissionScheduler private val userId = UUID.randomUUID() private val missionId = UUID.randomUUID() - @Test - fun `만료된 Lv2 미션을 WAIT_CONFIRM 상태로 변경한다`() { - val expiredMission = - UserMission( - userId = userId, - missionId = missionId, - status = MissionStatus.IN_PROGRESS, - progress = 1, - startedAt = LocalDateTime.now().minusDays(8), - ) - - missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + private fun mission() = + UserMission( + userId = userId, + missionId = missionId, + status = MissionStatus.IN_PROGRESS, + ) + @Test + fun `만료된 Lv2 미션을 프로세서에 위임한다`() { + val expiredMission = mission() whenever(userMissionRepository.findExpiredLv2Missions()).thenReturn(listOf(expiredMission)) - whenever(userMissionRepository.save(any())).thenReturn(expiredMission) missionScheduler.checkExpiredLv2Missions() - verify(userMissionRepository).findExpiredLv2Missions() - verify(userMissionRepository).save(expiredMission) + verify(missionFailureProcessor).failExpiredLv2(expiredMission) } @Test - fun `만료된 Lv2 미션이 없으면 아무것도 변경하지 않는다`() { - missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) - + fun `만료된 Lv2 미션이 없으면 프로세서를 호출하지 않는다`() { whenever(userMissionRepository.findExpiredLv2Missions()).thenReturn(emptyList()) missionScheduler.checkExpiredLv2Missions() @@ -65,57 +54,31 @@ class MissionSchedulerTest { } @Test - fun `지난주 회고가 없는 연속주차 미션을 WAIT_CONFIRM 상태로 변경한다`() { - val consecutiveWeekMission = - UserMission( - userId = userId, - missionId = missionId, - status = MissionStatus.IN_PROGRESS, - progress = 0, - ) - - missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) - - whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn( - listOf(consecutiveWeekMission), - ) - whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(0) - whenever(userMissionRepository.save(any())).thenReturn(consecutiveWeekMission) + fun `한 Lv2 미션 처리가 실패해도 나머지 미션은 처리된다`() { + val failing = mission() + val succeeding = mission() + whenever(userMissionRepository.findExpiredLv2Missions()).thenReturn(listOf(failing, succeeding)) + doThrow(RuntimeException("처리 실패")).whenever(missionFailureProcessor).failExpiredLv2(failing) - missionScheduler.checkConsecutiveWeekFailure() + missionScheduler.checkExpiredLv2Missions() - verify(userMissionRepository).findConsecutiveWeekMissionsInProgress() - verify(retrospectiveRepository).countCompletedByUserIdAndPeriod(any(), any(), any()) - verify(userMissionRepository).save(consecutiveWeekMission) + verify(missionFailureProcessor).failExpiredLv2(failing) + verify(missionFailureProcessor).failExpiredLv2(succeeding) } @Test - fun `지난주 회고가 있는 연속주차 미션은 변경하지 않는다`() { - val consecutiveWeekMission = - UserMission( - userId = userId, - missionId = missionId, - status = MissionStatus.IN_PROGRESS, - progress = 0, - ) - - missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) - - whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn( - listOf(consecutiveWeekMission), - ) - whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(1) + fun `연속주차 미션을 프로세서에 위임한다`() { + val consecutiveWeekMission = mission() + whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()) + .thenReturn(listOf(consecutiveWeekMission)) missionScheduler.checkConsecutiveWeekFailure() - verify(userMissionRepository).findConsecutiveWeekMissionsInProgress() - verify(retrospectiveRepository).countCompletedByUserIdAndPeriod(any(), any(), any()) + verify(missionFailureProcessor).failIfNoRetroLastWeek(consecutiveWeekMission) } @Test - fun `연속주차 진행 미션이 없으면 아무것도 변경하지 않는다`() { - missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) - + fun `연속주차 진행 미션이 없으면 프로세서를 호출하지 않는다`() { whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn(emptyList()) missionScheduler.checkConsecutiveWeekFailure() diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/NotificationSchedulerTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/NotificationSchedulerTest.kt index 19decf5b..17cede84 100644 --- a/src/test/kotlin/com/didit/adapter/integration/scheduler/NotificationSchedulerTest.kt +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/NotificationSchedulerTest.kt @@ -1,8 +1,6 @@ package com.didit.adapter.integration.scheduler -import com.didit.application.notification.provided.NotificationHistoryRegister import com.didit.application.notification.provided.NotificationSettingFinder -import com.didit.application.notification.provided.UserPushSender import com.didit.domain.notification.NotificationSetting import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -10,6 +8,7 @@ import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -20,9 +19,7 @@ import java.util.UUID class NotificationSchedulerTest { @Mock lateinit var notificationSettingFinder: NotificationSettingFinder - @Mock lateinit var notificationHistoryRegister: NotificationHistoryRegister - - @Mock lateinit var userPushSender: UserPushSender + @Mock lateinit var reminderNotificationSender: ReminderNotificationSender @InjectMocks lateinit var notificationScheduler: NotificationScheduler @@ -35,13 +32,7 @@ class NotificationSchedulerTest { notificationScheduler.sendReminderNotifications() - verify(notificationHistoryRegister).save(any()) - verify(userPushSender).sendToUser( - eq(userId), - eq(NotificationScheduler.DAILY_REMINDER_TITLE), - eq(NotificationScheduler.DAILY_REMINDER_BODY), - eq(NotificationScheduler.DAILY_REMINDER_LINK), - ) + verify(reminderNotificationSender).send(eq(userId)) } @Test @@ -50,7 +41,20 @@ class NotificationSchedulerTest { notificationScheduler.sendReminderNotifications() - verify(userPushSender, never()).sendToUser(any(), any(), any(), any()) - verify(notificationHistoryRegister, never()).save(any()) + verify(reminderNotificationSender, never()).send(any()) + } + + @Test + fun `한 유저 전송이 실패해도 나머지 유저에게는 전송된다`() { + val failing = UUID.randomUUID() + val succeeding = UUID.randomUUID() + whenever(notificationSettingFinder.findAllByReminderTime(any())) + .thenReturn(listOf(NotificationSetting.create(failing), NotificationSetting.create(succeeding))) + doThrow(RuntimeException("전송 실패")).whenever(reminderNotificationSender).send(failing) + + notificationScheduler.sendReminderNotifications() + + verify(reminderNotificationSender).send(failing) + verify(reminderNotificationSender).send(succeeding) } } diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSenderTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSenderTest.kt new file mode 100644 index 00000000..198b8ac6 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/ReminderNotificationSenderTest.kt @@ -0,0 +1,38 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.notification.provided.NotificationHistoryRegister +import com.didit.application.notification.provided.UserPushSender +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 org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class ReminderNotificationSenderTest { + @Mock lateinit var notificationHistoryRegister: NotificationHistoryRegister + + @Mock lateinit var userPushSender: UserPushSender + + @InjectMocks + lateinit var reminderNotificationSender: ReminderNotificationSender + + @Test + fun `send - 알림 이력을 저장하고 푸시를 전송한다`() { + val userId = UUID.randomUUID() + + reminderNotificationSender.send(userId) + + verify(notificationHistoryRegister).save(any()) + verify(userPushSender).sendToUser( + eq(userId), + eq(NotificationScheduler.DAILY_REMINDER_TITLE), + eq(NotificationScheduler.DAILY_REMINDER_BODY), + eq(NotificationScheduler.DAILY_REMINDER_LINK), + ) + } +}