From 9f5b7c8dfbe743583a85f383abd13e055b9f0f77 Mon Sep 17 00:00:00 2001 From: hyunw9 Date: Wed, 4 Sep 2024 01:18:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feat(infra)=20:=2010=EB=B6=84=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Scheduler + Cron tab을 이용하여 구현하였습니다. --- .../reply/ReplyScheduledServiceTest.java | 37 ------------------- .../clody/infra/config/SchedulingConfig.java | 10 +++++ .../models/alarm/DiaryNotifyService.java | 36 ++++++++++++++++++ 3 files changed, 46 insertions(+), 37 deletions(-) create mode 100644 clody-infra/src/main/java/com/clody/infra/config/SchedulingConfig.java create mode 100644 clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java diff --git a/clody-batch/src/test/java/com/clody/clodybatch/reply/ReplyScheduledServiceTest.java b/clody-batch/src/test/java/com/clody/clodybatch/reply/ReplyScheduledServiceTest.java index e0449f1..a7f557b 100644 --- a/clody-batch/src/test/java/com/clody/clodybatch/reply/ReplyScheduledServiceTest.java +++ b/clody-batch/src/test/java/com/clody/clodybatch/reply/ReplyScheduledServiceTest.java @@ -1,51 +1,14 @@ package com.clody.clodybatch.reply; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - -import com.clody.clodybatch.service.ReplyScheduleService; -import com.clody.domain.reply.ReplyType; -import com.clody.meta.Schedule; -import com.clody.meta.repository.ScheduleMetaRepository; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.List; 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; @ExtendWith(MockitoExtension.class) public class ReplyScheduledServiceTest { - @Mock - private ScheduleMetaRepository scheduleMetaRepository; - - @InjectMocks - private ReplyScheduleService replyScheduleService; - - @Test public void 스케줄_알림_발송할_대상_탐색() { - //given - LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); - - Long userId = 1L; - LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - ReplyType replyType = ReplyType.DYNAMIC; - - Schedule schedule = Schedule.create(userId, yesterday, replyType); - - //when - when(scheduleMetaRepository.findSchedulesToNotify(now)).thenReturn( - Collections.singletonList(schedule)); - - //then - List result = replyScheduleService.findSchedulesToNotify(now); - assertEquals(1, result.size()); - assertEquals(schedule, result.getFirst()); } } diff --git a/clody-infra/src/main/java/com/clody/infra/config/SchedulingConfig.java b/clody-infra/src/main/java/com/clody/infra/config/SchedulingConfig.java new file mode 100644 index 0000000..5da75e3 --- /dev/null +++ b/clody-infra/src/main/java/com/clody/infra/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package com.clody.infra.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + +} diff --git a/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java b/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java new file mode 100644 index 0000000..3881703 --- /dev/null +++ b/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java @@ -0,0 +1,36 @@ +package com.clody.infra.models.alarm; + +import com.clody.domain.alarm.Alarm; +import com.clody.domain.alarm.repository.AlarmRepository; +import com.clody.infra.external.fcm.FcmService; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiaryNotifyService { + + private final AlarmRepository alarmRepository; + private final FcmService fcmService; + + @Scheduled(cron = "0 0/10 * * * *") + public void sendDiaryAlarms() { + LocalTime time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS); + + List alarmTarget = alarmRepository.findAllByTime(time); + + alarmTarget.stream() + .filter(Alarm::isDiaryAlarm) + .forEach( + it -> fcmService.sendDiaryAlarm(it.getFcmToken()) + ); + log.info("Alarm Successfully Sent"); + } + +} From 735ce108a8c222dbc4594ff89fc66a2be601f7e5 Mon Sep 17 00:00:00 2001 From: hyunw9 Date: Wed, 4 Sep 2024 15:19:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EA=B0=84=20=EA=B3=A0=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Clock=20=EA=B0=9D=EC=B2=B4=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/clody/infra/config/TimeConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 clody-infra/src/main/java/com/clody/infra/config/TimeConfig.java diff --git a/clody-infra/src/main/java/com/clody/infra/config/TimeConfig.java b/clody-infra/src/main/java/com/clody/infra/config/TimeConfig.java new file mode 100644 index 0000000..07fc4e2 --- /dev/null +++ b/clody-infra/src/main/java/com/clody/infra/config/TimeConfig.java @@ -0,0 +1,15 @@ +package com.clody.infra.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock(){ + return Clock.systemDefaultZone(); + } + +} From a183afee153694fade3b528600545c92ba7b2b34 Mon Sep 17 00:00:00 2001 From: hyunw9 Date: Wed, 4 Sep 2024 15:20:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=20Test=20:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EB=8B=A8=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alarm/fcm/DiarySchedulingTest.java | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 clody-infra/src/test/java/com/clody/infrastructure/alarm/fcm/DiarySchedulingTest.java diff --git a/clody-infra/src/test/java/com/clody/infrastructure/alarm/fcm/DiarySchedulingTest.java b/clody-infra/src/test/java/com/clody/infrastructure/alarm/fcm/DiarySchedulingTest.java new file mode 100644 index 0000000..b5d748f --- /dev/null +++ b/clody-infra/src/test/java/com/clody/infrastructure/alarm/fcm/DiarySchedulingTest.java @@ -0,0 +1,160 @@ +package com.clody.infrastructure.alarm.fcm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.clody.domain.alarm.Alarm; +import com.clody.domain.alarm.repository.AlarmRepository; +import com.clody.domain.user.Platform; +import com.clody.domain.user.User; +import com.clody.infra.external.fcm.FcmService; +import com.clody.infra.models.alarm.DiaryNotifyService; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@ExtendWith(MockitoExtension.class) +public class DiarySchedulingTest { + + @Mock + private FcmService fcmService; + + @Mock + private AlarmRepository alarmRepository; + + @InjectMocks + private DiaryNotifyService diaryNotifyService; + + @Mock + private Clock fixedClock; + + @BeforeEach + void setUp() { + fixedClock = Clock.fixed(Instant.parse("2024-09-04T14:00:00Z"), ZoneId.systemDefault()); + diaryNotifyService = new DiaryNotifyService(alarmRepository, fcmService, fixedClock); + } + + @Test + @DisplayName("현재시각은 정오 이후로 고정") + void test_IsAfterNoon() { + assertThat(LocalTime.NOON.isAfter(LocalTime.now(fixedClock))).isEqualTo(false); + } + + @Test + @DisplayName("유저가 설정한 시간에 알림이 발송되어야 한다.") + public void test1() throws InterruptedException { + + User user = User.createNewUser("1L", Platform.KAKAO, null, "John doe", "test1@naver.com"); + Alarm alarm = new Alarm(user, "fcmToken", LocalTime.of(14, 0), true, + true); + + CountDownLatch latch = new CountDownLatch(1); + + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + + when(alarmRepository.findAllByTime(LocalTime.now(fixedClock))).thenReturn( + Collections.singletonList(alarm)); + + Runnable task = () -> { + System.out.println("알림 발송 "); + diaryNotifyService.sendDiaryAlarms(); + latch.countDown(); + }; + + scheduler.scheduleAtFixedRate(task, Instant.now(), Duration.ofSeconds(1)); + boolean taskExecuted = latch.await(5, TimeUnit.SECONDS); + + verify(fcmService).sendDiaryAlarm("fcmToken"); + } + + @Test + @DisplayName("고정된 시간 14:00에 알림이 정상적으로 발송되어야 한다") + public void testSendDiaryAlarmsAtFixedTime() { + // 14:00에 대한 알람 데이터 설정 + User user = User.createNewUser("1L", Platform.KAKAO, null, "John Doe", "test1@naver.com"); + Alarm alarm = new Alarm(user, "fcmToken", LocalTime.of(14, 0), true, true); + + // Mock 설정: 14:00에 알람이 반환되도록 설정 + when(alarmRepository.findAllByTime(LocalTime.now(fixedClock))).thenReturn(Arrays.asList(alarm)); + + // 알림 발송 메서드 호출 + diaryNotifyService.sendDiaryAlarms(); + + // FCM 서비스가 정상적으로 호출되었는지 확인 + verify(fcmService).sendDiaryAlarm("fcmToken"); + } + + @Test + @DisplayName("스케쥴링이 진행되는 시간은 10분 단위여야 한다.") + public void validate_schedule_time() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + + Runnable task = () -> { + System.out.println("10분 단위로 작업 실행"); + latch.countDown(); + }; + + // 스케줄링이 1초마다 실행되도록 설정 + scheduler.scheduleAtFixedRate(task, Instant.now(), Duration.ofSeconds(1)); + + // 10분 후에 실행되었는지 대기 (테스트에서는 짧게 대기) + boolean taskExecuted = latch.await(2, TimeUnit.SECONDS); + + // 스케줄링이 올바르게 동작했는지 확인 + assertThat(taskExecuted).isTrue(); + } + + @Test + @DisplayName("알림 설정을 허용한 유저에게만 발송되어야 한다.") + public void validAlarmUser() throws InterruptedException { + User userWithAlarm = User.createNewUser("1L", Platform.KAKAO, null, "John doe", "test1@naver.com"); + Alarm alarmWithPermission = new Alarm(userWithAlarm, "fcmToken", LocalTime.of(14, 0), true, true); + + User userWithoutAlarm = User.createNewUser("2L", Platform.KAKAO, null, "Jane doe", "test2@naver.com"); + Alarm alarmWithoutPermission = new Alarm(userWithoutAlarm, "fcmToken", LocalTime.of(14, 0), false, false); + + when(alarmRepository.findAllByTime(LocalTime.now(fixedClock))).thenReturn( + List.of(alarmWithPermission, alarmWithoutPermission)); + + CountDownLatch latch = new CountDownLatch(1); + + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + + Runnable task = () -> { + diaryNotifyService.sendDiaryAlarms(); + latch.countDown(); + }; + scheduler.scheduleAtFixedRate(task, Instant.now(), Duration.ofSeconds(1)); + + boolean taskExecuted = latch.await(5, TimeUnit.SECONDS); + + // 2명중 한명만 발송되어야 함. + verify(fcmService, times(1)).sendDiaryAlarm(alarmWithPermission.getFcmToken()); // 알림 허용 유저 + + assertThat(taskExecuted).isTrue(); + } + + +} From 37bd5efad78d7526ccb4e516e6b0a8e7d753cb2c Mon Sep 17 00:00:00 2001 From: hyunw9 Date: Wed, 4 Sep 2024 15:20:28 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Feat=20:=20Service=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/clody/infra/models/alarm/DiaryNotifyService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java b/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java index 3881703..f212b51 100644 --- a/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java +++ b/clody-infra/src/main/java/com/clody/infra/models/alarm/DiaryNotifyService.java @@ -3,6 +3,7 @@ import com.clody.domain.alarm.Alarm; import com.clody.domain.alarm.repository.AlarmRepository; import com.clody.infra.external.fcm.FcmService; +import java.time.Clock; import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.List; @@ -18,10 +19,11 @@ public class DiaryNotifyService { private final AlarmRepository alarmRepository; private final FcmService fcmService; + private final Clock clock; @Scheduled(cron = "0 0/10 * * * *") public void sendDiaryAlarms() { - LocalTime time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS); + LocalTime time = LocalTime.now(clock).truncatedTo(ChronoUnit.SECONDS); List alarmTarget = alarmRepository.findAllByTime(time); From 07cf191a9d69d5bafb0733419dd30d7a2710ade7 Mon Sep 17 00:00:00 2001 From: hyunw9 Date: Wed, 4 Sep 2024 15:20:39 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Fix=20:=20Schedule=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/clody/meta/Schedule.java | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/clody-domain/src/main/java/com/clody/meta/Schedule.java b/clody-domain/src/main/java/com/clody/meta/Schedule.java index 054fc08..71e8087 100644 --- a/clody-domain/src/main/java/com/clody/meta/Schedule.java +++ b/clody-domain/src/main/java/com/clody/meta/Schedule.java @@ -1,15 +1,11 @@ package com.clody.meta; -import com.clody.domain.reply.ReplyType; -import com.clody.support.dto.type.ErrorType; -import com.clody.support.exception.BusinessException; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.LocalTime; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,38 +22,22 @@ public class Schedule { private Long userId; - private LocalDateTime notificationTime; + private LocalTime notificationTime; private boolean notificationSent; @Builder - public Schedule(Long userId, LocalDateTime notificationTime) { + public Schedule(Long userId, LocalTime notificationTime) { this.userId = userId; this.notificationTime = notificationTime; } @Builder - public static Schedule create(Long userId, LocalDateTime replyCreationTime, ReplyType type) { - - LocalDateTime notificationTime = calculateNotificationTime(replyCreationTime, type); - return new Schedule(userId, notificationTime); + public static Schedule create(Long userId, LocalTime userRequestedTime) { + return new Schedule(userId, userRequestedTime); } public void notifySent() { this.notificationSent = true; } - - private static LocalDateTime calculateNotificationTime(LocalDateTime replyCreationTime, ReplyType type) { - replyCreationTime = replyCreationTime.truncatedTo(ChronoUnit.SECONDS); - return switch (type) { - case FIRST -> replyCreationTime.plusMinutes(10); - case STATIC -> replyCreationTime.plusHours(5); - case DYNAMIC -> replyCreationTime.plusHours(10); - default -> throw new BusinessException(ErrorType.INVALID_REPLY_TYPE); - }; - } - - public static Schedule truncateSeconds(Schedule schedule){ - return new Schedule(schedule.getUserId(), schedule.getNotificationTime().truncatedTo(ChronoUnit.SECONDS)); - } }