diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java new file mode 100644 index 00000000..7840c637 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/notifications/survey-schedules") +public class AdminSurveyPushScheduleController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PutMapping("/{surveyKey}") + public CommonResponse upsertSurveySchedules( + @PathVariable String surveyKey, + @RequestBody AdminSurveyPushScheduleReq request + ) { + return CommonResponse.success(surveyPushScheduleService.upsertAdminSchedules(surveyKey, request)); + } + + @GetMapping("/{surveyKey}") + public CommonResponse getSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.getAdminSchedules(surveyKey)); + } + + @DeleteMapping("/{surveyKey}") + public CommonResponse cancelSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.cancelSchedules(surveyKey)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java new file mode 100644 index 00000000..a415ea11 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/surveys") +public class SurveyNotificationController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PostMapping("/{surveyKey}/reminders") + public CommonResponse remindAfterLater( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @PathVariable String surveyKey + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + return CommonResponse.success(surveyPushScheduleService.remindAfterLater( + surveyKey, + userDetail.getUser().getUserId() + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java new file mode 100644 index 00000000..d9c352af --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleReq( + LocalDateTime startNotificationAt, + LocalDateTime deadlineNotificationAt, + Integer rewardPoint +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java new file mode 100644 index 00000000..3a2428b4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +public record AdminSurveyPushScheduleRes( + String surveyKey, + int rewardPoint, + AdminSurveyPushScheduleStageRes started, + AdminSurveyPushScheduleStageRes dMinus3, + AdminSurveyPushScheduleStageRes deadline +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java new file mode 100644 index 00000000..5428cc6b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleStageRes( + SurveyPushScheduleStatus status, + LocalDateTime scheduledAt +) { + + public static AdminSurveyPushScheduleStageRes from(SurveyPushSchedule schedule) { + if (schedule == null) { + return null; + } + return new AdminSurveyPushScheduleStageRes( + schedule.getStatus(), + schedule.getScheduledAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java new file mode 100644 index 00000000..0b1e704f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import java.time.LocalDateTime; + +public record SurveyReminderRes( + String surveyKey, + boolean scheduled, + LocalDateTime scheduledAt, + SurveyReminderSuppressedBy suppressedBy +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java new file mode 100644 index 00000000..7ca41786 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java @@ -0,0 +1,122 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_survey_push_schedule", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_survey_push_schedule_idempotency_key", + columnNames = "idempotency_key" + ) + }, + indexes = { + @Index( + name = "idx_survey_push_schedule_status_scheduled_at", + columnList = "status, scheduled_at" + ) + } +) +@NoArgsConstructor +@Getter +public class SurveyPushSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "survey_push_schedule_id") + private Long surveyPushScheduleId; + + @Column(name = "survey_key", nullable = false, length = 64) + private String surveyKey; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_stage", nullable = false, length = 40) + private SurveyNotificationStage notificationStage; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @Column(name = "reward_point", nullable = false) + private int rewardPoint; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private SurveyPushScheduleStatus status; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + public SurveyPushSchedule( + String surveyKey, + SurveyNotificationStage notificationStage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint, + String idempotencyKey, + LocalDateTime createdAt + ) { + this.surveyKey = surveyKey; + this.notificationStage = notificationStage; + this.targetUserId = targetUserId; + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + this.status = SurveyPushScheduleStatus.PENDING; + this.idempotencyKey = idempotencyKey; + this.createdAt = createdAt; + this.processedAt = null; + } + + public boolean isPending() { + return SurveyPushScheduleStatus.PENDING.equals(status); + } + + public void updatePendingSchedule( + LocalDateTime scheduledAt, + int rewardPoint + ) { + if (!isPending()) { + return; + } + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + } + + public void complete(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.COMPLETED; + this.processedAt = now; + } + + public void cancel(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.CANCELLED; + this.processedAt = now; + } + + public void skip(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.SKIPPED; + this.processedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java index 52d298c5..3445875f 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java @@ -3,7 +3,8 @@ public enum PushEventType { CROWD("push:event:crowd-enabled"), REPORT("push:event:report-enabled"), - CHARACTER("push:event:character-enabled"); + CHARACTER("push:event:character-enabled"), + SURVEY("push:event:survey-enabled"); private final String redisKey; diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java index 01c41341..296d2715 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java @@ -3,5 +3,6 @@ public enum PushTargetType { INSTALLATION, USER, - USER_GROUP + USER_GROUP, + ALL } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java new file mode 100644 index 00000000..8891eac6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyNotificationStage { + STARTED, + D_MINUS_3, + DEADLINE, + REMIND_AFTER_LATER +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java new file mode 100644 index 00000000..8e06baed --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyPushScheduleStatus { + PENDING, + COMPLETED, + CANCELLED, + SKIPPED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java new file mode 100644 index 00000000..61fb3b1f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyReminderSuppressedBy { + NONE, + D3, + DEADLINE, + EXPIRED, + ALREADY_PROCESSED, + ALREADY_PARTICIPATED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java index 3d8e1124..96ec8433 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -40,11 +40,19 @@ List findAllByUserIdAndAppVariantAndActiveTrue( AppVariant appVariant ); + List findAllByAppVariantAndActiveTrue( + AppVariant appVariant + ); + boolean existsByUserIdAndAppVariantAndActiveTrue( Long userId, AppVariant appVariant ); + boolean existsByAppVariantAndActiveTrue( + AppVariant appVariant + ); + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( Long pushInstallationId, String installationId, diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java new file mode 100644 index 00000000..9cd0b446 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java @@ -0,0 +1,51 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SurveyPushScheduleRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); + + List findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(String surveyKey); + + List findAllBySurveyKeyAndNotificationStageIn( + String surveyKey, + Collection notificationStages + ); + + Optional findBySurveyKeyAndNotificationStage( + String surveyKey, + SurveyNotificationStage notificationStage + ); + + @Query( + value = """ + SELECT * + FROM tb_survey_push_schedule + WHERE status = 'PENDING' + AND scheduled_at <= :now + ORDER BY scheduled_at ASC, survey_push_schedule_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDuePendingForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + List findAllByStatusAndScheduledAtLessThanEqualOrderByScheduledAtAscSurveyPushScheduleIdAsc( + SurveyPushScheduleStatus status, + LocalDateTime now + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java index b0acba42..0f8d3694 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java @@ -33,6 +33,7 @@ public List resolve( case INSTALLATION -> resolveInstallation(targetValue, appVariant); case USER -> resolveUser(targetValue, appVariant); case USER_GROUP -> throw new GlobalException(UNSUPPORTED_REQUEST); + case ALL -> resolveAll(targetValue, appVariant); }; List distinctInstallations = distinctByInstallation(installations); @@ -68,6 +69,17 @@ private List resolveUser( ); } + private List resolveAll( + String targetValue, + AppVariant appVariant + ) { + if (!"ALL".equals(targetValue)) { + throw new GlobalException(INVALID_INPUT); + } + + return pushInstallationRepository.findAllByAppVariantAndActiveTrue(appVariant); + } + private List distinctByInstallation(List installations) { Map distinct = new LinkedHashMap<>(); diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java index 7e621e03..09a5793e 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -24,6 +24,9 @@ public class PushEventFlagService { @Value("${push.event.character-enabled:false}") private boolean characterDefaultEnabled; + @Value("${push.event.survey-enabled:false}") + private boolean surveyDefaultEnabled; + public boolean isEnabled(PushEventType eventType) { String redisValue = getRedisValue(eventType); if ("true".equalsIgnoreCase(redisValue)) { @@ -62,6 +65,7 @@ private boolean defaultEnabled(PushEventType eventType) { case CROWD -> crowdDefaultEnabled; case REPORT -> reportDefaultEnabled; case CHARACTER -> characterDefaultEnabled; + case SURVEY -> surveyDefaultEnabled; }; } } diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java new file mode 100644 index 00000000..b27189b4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java @@ -0,0 +1,280 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleStageRes; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SurveyPushScheduleService { + + private static final int MAX_SURVEY_KEY_LENGTH = 64; + private static final List ADMIN_STAGES = List.of( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final Clock clock; + + @Transactional + public AdminSurveyPushScheduleRes upsertAdminSchedules( + String surveyKey, + AdminSurveyPushScheduleReq request + ) { + validateSurveyKey(surveyKey); + validateAdminRequest(request); + + LocalDateTime dMinus3At = request.deadlineNotificationAt().minusDays(3); + if (!request.startNotificationAt().isBefore(dMinus3At)) { + throw new GlobalException(INVALID_INPUT); + } + + upsertSchedule( + surveyKey, + SurveyNotificationStage.STARTED, + null, + request.startNotificationAt(), + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.D_MINUS_3, + null, + dMinus3At, + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.DEADLINE, + null, + request.deadlineNotificationAt(), + request.rewardPoint() + ); + + return getAdminSchedules(surveyKey); + } + + public AdminSurveyPushScheduleRes getAdminSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + Map schedules = new EnumMap<>(SurveyNotificationStage.class); + surveyPushScheduleRepository.findAllBySurveyKeyAndNotificationStageIn(surveyKey, ADMIN_STAGES) + .forEach(schedule -> schedules.put(schedule.getNotificationStage(), schedule)); + + int rewardPoint = schedules.values() + .stream() + .findFirst() + .map(SurveyPushSchedule::getRewardPoint) + .orElse(0); + + return new AdminSurveyPushScheduleRes( + surveyKey, + rewardPoint, + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.STARTED)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.D_MINUS_3)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.DEADLINE)) + ); + } + + @Transactional + public AdminSurveyPushScheduleRes cancelSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + LocalDateTime now = LocalDateTime.now(clock); + surveyPushScheduleRepository.findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(surveyKey) + .stream() + .filter(SurveyPushSchedule::isPending) + .forEach(schedule -> schedule.cancel(now)); + + return getAdminSchedules(surveyKey); + } + + @Transactional + public SurveyReminderRes remindAfterLater( + String surveyKey, + Long userId + ) { + validateSurveyKey(surveyKey); + if (userId == null || userId <= 0) { + throw new GlobalException(INVALID_INPUT); + } + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.DEADLINE) + .orElseThrow(() -> new GlobalException(INVALID_INPUT)); + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime remindAt = now.plusDays(1); + String idempotencyKey = idempotencyKey( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId + ); + + SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); + if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { + cancelPendingReminderIfExists(idempotencyKey, now); + return new SurveyReminderRes(surveyKey, false, null, suppressedBy); + } + + return surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .map(existing -> updateExistingReminder(surveyKey, remindAt, deadline.getRewardPoint(), existing)) + .orElseGet(() -> createReminder(surveyKey, userId, remindAt, deadline.getRewardPoint(), idempotencyKey)); + } + + private void upsertSchedule( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint + ) { + String idempotencyKey = idempotencyKey(surveyKey, stage, targetUserId); + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .ifPresentOrElse( + schedule -> schedule.updatePendingSchedule(scheduledAt, rewardPoint), + () -> surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + stage, + targetUserId, + scheduledAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )) + ); + } + + private SurveyReminderRes updateExistingReminder( + String surveyKey, + LocalDateTime remindAt, + int rewardPoint, + SurveyPushSchedule existing + ) { + if (!existing.isPending()) { + return new SurveyReminderRes( + surveyKey, + false, + null, + SurveyReminderSuppressedBy.ALREADY_PROCESSED + ); + } + + existing.updatePendingSchedule(remindAt, rewardPoint); + return new SurveyReminderRes( + surveyKey, + true, + existing.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private SurveyReminderRes createReminder( + String surveyKey, + Long userId, + LocalDateTime remindAt, + int rewardPoint, + String idempotencyKey + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + SurveyPushSchedule saved = surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId, + remindAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )); + + return new SurveyReminderRes( + surveyKey, + true, + saved.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private void cancelPendingReminderIfExists( + String idempotencyKey, + LocalDateTime now + ) { + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .filter(SurveyPushSchedule::isPending) + .ifPresent(schedule -> schedule.cancel(now)); + } + + private SurveyReminderSuppressedBy suppressionForReminder( + String surveyKey, + LocalDateTime remindAt, + LocalDateTime deadlineAt + ) { + if (remindAt.isAfter(deadlineAt)) { + return SurveyReminderSuppressedBy.EXPIRED; + } + + LocalDate remindDate = remindAt.toLocalDate(); + if (remindDate.equals(deadlineAt.toLocalDate())) { + return SurveyReminderSuppressedBy.DEADLINE; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(remindDate::equals) + .map(ignored -> SurveyReminderSuppressedBy.D3) + .orElse(SurveyReminderSuppressedBy.NONE); + } + + private void validateAdminRequest(AdminSurveyPushScheduleReq request) { + if (request == null + || request.startNotificationAt() == null + || request.deadlineNotificationAt() == null + || request.rewardPoint() == null + || request.rewardPoint() < 0) { + throw new GlobalException(INVALID_INPUT); + } + + if (!request.startNotificationAt().isBefore(request.deadlineNotificationAt())) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateSurveyKey(String surveyKey) { + if (surveyKey == null || surveyKey.isBlank() || surveyKey.length() > MAX_SURVEY_KEY_LENGTH) { + throw new GlobalException(INVALID_INPUT); + } + } + + public static String idempotencyKey( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(stage)) { + return "survey:" + surveyKey + ":" + stage.name() + ":" + targetUserId; + } + return "survey:" + surveyKey + ":" + stage.name(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java new file mode 100644 index 00000000..c435cda2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -0,0 +1,183 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyPushScheduleWorker { + + private static final int BATCH_SIZE = 50; + private static final Long SYSTEM_CREATED_BY = 0L; + private static final String ALL_TARGET_VALUE = "ALL"; + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + private final Clock clock; + + @Scheduled(fixedDelayString = "${push.survey.poll-interval-ms:60000}") + @Transactional + public void processDueSchedules() { + processDueSchedulesOnce(); + } + + public int processDueSchedulesOnce() { + if (!pushEventFlagService.isEnabled(PushEventType.SURVEY)) { + return 0; + } + + LocalDateTime now = LocalDateTime.now(clock); + List schedules = surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked( + now, + BATCH_SIZE + ); + + schedules.forEach(schedule -> processSchedule(schedule, now)); + return schedules.size(); + } + + private void processSchedule( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage()) + && cancelReminderByLatestPriority(schedule, now)) { + return; + } + + if (!hasActiveTarget(schedule)) { + schedule.skip(now); + return; + } + + try { + pushDispatchService.enqueue(command(schedule)); + schedule.complete(now); + } catch (GlobalException e) { + log.warn( + "Survey push schedule processing failed. scheduleId={}, stage={}, resultCode={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e.getResultCode() + ); + } catch (RuntimeException e) { + log.warn( + "Unexpected survey push schedule processing failure. scheduleId={}, stage={}, exception={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e.getClass().getSimpleName() + ); + } + } + + private boolean cancelReminderByLatestPriority( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + LocalDate executionDate = schedule.getScheduledAt().toLocalDate(); + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.DEADLINE) + .orElse(null); + if (deadline == null) { + schedule.cancel(now); + return true; + } + + if (executionDate.equals(deadline.getScheduledAt().toLocalDate()) + || now.isAfter(deadline.getScheduledAt())) { + schedule.cancel(now); + return true; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(executionDate::equals) + .map(ignored -> { + schedule.cancel(now); + return true; + }) + .orElse(false); + } + + private PushDispatchCommand command(SurveyPushSchedule schedule) { + PushContent content = content(schedule); + + return new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + targetType(schedule), + targetValue(schedule), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + schedule.getIdempotencyKey(), + SYSTEM_CREATED_BY + ); + } + + private PushContent content(SurveyPushSchedule schedule) { + return switch (schedule.getNotificationStage()) { + case STARTED -> DomainPushContentFactory.surveyStarted(); + case D_MINUS_3 -> DomainPushContentFactory.surveyDMinus3(); + case DEADLINE -> DomainPushContentFactory.surveyDeadline(schedule.getRewardPoint()); + case REMIND_AFTER_LATER -> DomainPushContentFactory.surveyRemindAfterLater(schedule.getRewardPoint()); + }; + } + + private PushTargetType targetType(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return PushTargetType.USER; + } + return PushTargetType.ALL; + } + + private String targetValue(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return String.valueOf(schedule.getTargetUserId()); + } + return ALL_TARGET_VALUE; + } + + private boolean hasActiveTarget(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return schedule.getTargetUserId() != null + && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + schedule.getTargetUserId(), + AppVariant.PRODUCTION + ); + } + + return pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java index 1e1581e4..a3041462 100644 --- a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java @@ -47,6 +47,34 @@ public static PushContent characterUnlocked(String characterName) { ); } + public static PushContent surveyStarted() { + return new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDMinus3() { + return new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDeadline(int rewardPoint) { + return new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 " + rewardPoint + " 포인트를 받을 수 있어요.(5초 소요)" + ); + } + + public static PushContent surveyRemindAfterLater(int rewardPoint) { + return new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 " + rewardPoint + "포인트를 받아보세요.(5초 소요)" + ); + } + private static String joinNonBlank( String first, String second diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 863190c8..6eacf1f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -167,6 +167,9 @@ push: crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} + survey-enabled: ${PUSH_EVENT_SURVEY_ENABLED:false} + survey: + poll-interval-ms: ${PUSH_SURVEY_POLL_INTERVAL_MS:60000} worker: enabled: ${PUSH_WORKER_ENABLED:false} fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} diff --git a/src/main/resources/db/migration/V1__create_survey_push_schedule.sql b/src/main/resources/db/migration/V1__create_survey_push_schedule.sql new file mode 100644 index 00000000..ac4c37f3 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_survey_push_schedule.sql @@ -0,0 +1,18 @@ +CREATE TABLE tb_survey_push_schedule ( + survey_push_schedule_id BIGINT NOT NULL AUTO_INCREMENT, + survey_key VARCHAR(64) NOT NULL, + notification_stage VARCHAR(40) NOT NULL, + target_user_id BIGINT NULL, + scheduled_at DATETIME(6) NOT NULL, + reward_point INT NOT NULL, + status VARCHAR(30) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + created_at DATETIME(6) NOT NULL, + processed_at DATETIME(6) NULL, + PRIMARY KEY (survey_push_schedule_id), + CONSTRAINT uk_survey_push_schedule_idempotency_key UNIQUE (idempotency_key), + CONSTRAINT chk_survey_push_schedule_reward_point CHECK (reward_point >= 0) +); + +CREATE INDEX idx_survey_push_schedule_status_scheduled_at + ON tb_survey_push_schedule (status, scheduled_at); diff --git a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java new file mode 100644 index 00000000..1978fc94 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java @@ -0,0 +1,53 @@ +package devkor.com.teamcback.domain.notification.resolver; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushTargetResolverTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + private PushTargetResolver resolver; + + @BeforeEach + void setUp() { + resolver = new PushTargetResolver(pushInstallationRepository); + } + + @Test + void allTargetResolvesDistinctActiveProductionInstallations() { + PushInstallation first = new PushInstallation(1L, "install-1", "ExponentPushToken[first]", AppVariant.PRODUCTION); + PushInstallation duplicate = new PushInstallation(2L, "install-1", "ExponentPushToken[duplicate]", AppVariant.PRODUCTION); + PushInstallation second = new PushInstallation(3L, "install-2", "ExponentPushToken[second]", AppVariant.PRODUCTION); + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of(first, duplicate, second)); + + List resolved = resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION); + + assertThat(resolved).containsExactly(first, second); + } + + @Test + void allTargetRejectsNonAllTargetValue() { + assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "1", AppVariant.PRODUCTION)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java index c554106b..6b293256 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -32,6 +32,7 @@ void setUp() { ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); + ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); when(redisTemplate.opsForValue()).thenReturn(valueOperations); } @@ -40,9 +41,11 @@ void setUp() { void returnsYamlDefaultWhenRedisValueDoesNotExist() { when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.SURVEY.redisKey())).thenReturn(null); assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + assertThat(service.isEnabled(PushEventType.SURVEY)).isFalse(); } @Test diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java new file mode 100644 index 00000000..494b3000 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java @@ -0,0 +1,322 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleServiceTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-06T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository repository; + + private SurveyPushScheduleService service; + + @BeforeEach + void setUp() { + service = new SurveyPushScheduleService(repository, FIXED_CLOCK); + } + + @Test + void upsertAdminSchedulesCreatesThreeWholeAudienceSchedules() { + List saved = new ArrayList<>(); + when(repository.findByIdempotencyKey(any())).thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> { + SurveyPushSchedule schedule = invocation.getArgument(0); + saved.add(schedule); + return schedule; + }); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenAnswer(ignored -> saved); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + ); + + assertThat(saved).hasSize(3); + assertThat(saved) + .extracting(SurveyPushSchedule::getNotificationStage) + .containsExactly( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + assertThat(saved.get(1).getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(saved).allSatisfy(schedule -> { + assertThat(schedule.getTargetUserId()).isNull(); + assertThat(schedule.getRewardPoint()).isEqualTo(100); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + }); + } + + @Test + void upsertAdminSchedulesUpdatesOnlyPendingExistingSchedule() { + SurveyPushSchedule pending = schedule(SurveyNotificationStage.STARTED, null, "2026-08-10T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-17T10:01:00")); + + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":STARTED")) + .thenReturn(Optional.of(pending)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":D_MINUS_3")) + .thenReturn(Optional.of(completed)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":DEADLINE")) + .thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenReturn(List.of(pending, completed)); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-11T10:00:00"), + LocalDateTime.parse("2026-08-21T10:00:00"), + 200 + ) + ); + + assertThat(pending.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-11T10:00:00")); + assertThat(pending.getRewardPoint()).isEqualTo(200); + assertThat(completed.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(completed.getRewardPoint()).isEqualTo(100); + assertThat(completed.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void upsertAdminSchedulesValidatesTimesAndRewardPoint() { + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-18T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + -1 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void remindAfterLaterCreatesOrUpdatesOnePendingPersonalSchedule() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 50); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isTrue(); + assertThat(response.scheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-07T10:00:00")); + assertThat(existing.getRewardPoint()).isEqualTo(100); + } + + @Test + void remindAfterLaterSuppressesByPriorityAndExpiry() { + assertReminderSuppressed( + "2026-08-07T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.DEADLINE + ); + assertReminderSuppressed( + "2026-08-20T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.D3 + ); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByD3() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByDeadline() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T11:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.DEADLINE); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenExpired() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterDoesNotCreateCancelledRowWhenSuppressedWithoutExistingReminder() { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterDoesNotRecreateProcessedReminder() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-07T09:01:00")); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(completed)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.ALREADY_PROCESSED); + } + + private void assertReminderSuppressed( + String deadlineAt, + String d3At, + SurveyReminderSuppressedBy suppressedBy + ) { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, deadlineAt, 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, d3At, 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(suppressedBy); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java new file mode 100644 index 00000000..226c2391 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java @@ -0,0 +1,276 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleWorkerTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-17T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository surveyPushScheduleRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private SurveyPushScheduleWorker worker; + + @BeforeEach + void setUp() { + worker = new SurveyPushScheduleWorker( + surveyPushScheduleRepository, + pushInstallationRepository, + pushDispatchService, + pushEventFlagService, + FIXED_CLOCK + ); + } + + @Test + void surveyFlagFalseDoesNotClaimOrEnqueue() { + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(false); + + int processed = worker.processDueSchedulesOnce(); + + assertThat(processed).isZero(); + verify(surveyPushScheduleRepository, never()).findDuePendingForUpdateSkipLocked(any(), any(Integer.class)); + verify(pushDispatchService, never()).enqueue(any()); + } + + @Test + void dueStartedScheduleEnqueuesAllProductionActualGeneralAndCompletes() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); + assertThat(command.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(command.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(command.targetType()).isEqualTo(PushTargetType.ALL); + assertThat(command.targetValue()).isEqualTo("ALL"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void dueReminderScheduleEnqueuesUserTarget() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-18T10:00:00", 100))); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.USER); + assertThat(captor.getValue().targetValue()).isEqualTo("7"); + assertThat(captor.getValue().title()).isEqualTo("잠깐, 설문을 잊지 않으셨나요?"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void noActiveTargetMarksSkippedWithProcessedAt() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(false); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.SKIPPED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void pushDispatchInvalidInputNotCausedByNoTargetKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.INVALID_INPUT)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void pushDispatchGlobalExceptionKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.UNSUPPORTED_REQUEST)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void pushDispatchRuntimeExceptionKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new RuntimeException("boom")); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void reminderIsCancelledWhenLatestD3DateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + } + + @Test + void reminderIsCancelledWhenLatestDeadlineDateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void reminderIsCancelledWhenNowIsAfterDeadline() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-16T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:59:59", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void deadlineAndD3SchedulesDoNotRunReminderPriorityCancellation() { + SurveyPushSchedule started = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule d3 = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(started, d3, deadline)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + verify(surveyPushScheduleRepository, never()).findBySurveyKeyAndNotificationStage(any(), any()); + verify(pushDispatchService, times(3)).enqueue(any()); + assertThat(started.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(d3.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(deadline.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java index 0ea3ef2c..4e78095c 100644 --- a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java +++ b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java @@ -52,4 +52,27 @@ void reportResolvedCreatesConfiguredTitleAndBody() { assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); } + @Test + void surveyContentsAreConfiguredExactly() { + assertThat(DomainPushContentFactory.surveyStarted()) + .isEqualTo(new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDMinus3()) + .isEqualTo(new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDeadline(100)) + .isEqualTo(new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 100 포인트를 받을 수 있어요.(5초 소요)" + )); + assertThat(DomainPushContentFactory.surveyRemindAfterLater(100)) + .isEqualTo(new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 100포인트를 받아보세요.(5초 소요)" + )); + } }