Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/#189: 10분단위 스케줄링 구현 #190

Merged
merged 5 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Schedule> result = replyScheduleService.findSchedulesToNotify(now);
assertEquals(1, result.size());
assertEquals(schedule, result.getFirst());
}
}
30 changes: 5 additions & 25 deletions clody-domain/src/main/java/com/clody/meta/Schedule.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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 {

}
15 changes: 15 additions & 0 deletions clody-infra/src/main/java/com/clody/infra/config/TimeConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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.Clock;
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;
private final Clock clock;

@Scheduled(cron = "0 0/10 * * * *")
public void sendDiaryAlarms() {
LocalTime time = LocalTime.now(clock).truncatedTo(ChronoUnit.SECONDS);

List<Alarm> alarmTarget = alarmRepository.findAllByTime(time);

alarmTarget.stream()
.filter(Alarm::isDiaryAlarm)
.forEach(
it -> fcmService.sendDiaryAlarm(it.getFcmToken())
);
log.info("Alarm Successfully Sent");
}

}
Original file line number Diff line number Diff line change
@@ -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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
Alarm alarmWithPermission = new Alarm(userWithAlarm, "fcmToken", LocalTime.of(14, 0), true, true);

User userWithoutAlarm = User.createNewUser("2L", Platform.KAKAO, null, "Jane doe", "[email protected]");
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();
}


}
Loading