diff --git a/src/main/java/com/challenge/api/service/notification/AchieveChallengeDTO.java b/src/main/java/com/challenge/api/service/notification/AchieveChallengeDTO.java new file mode 100644 index 0000000..21dc04c --- /dev/null +++ b/src/main/java/com/challenge/api/service/notification/AchieveChallengeDTO.java @@ -0,0 +1,16 @@ +package com.challenge.api.service.notification; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class AchieveChallengeDTO { + + Long memberId; + String nickname; + List challengeTitles; + +} diff --git a/src/main/java/com/challenge/api/service/notification/NewChallengeDTO.java b/src/main/java/com/challenge/api/service/notification/NewChallengeDTO.java new file mode 100644 index 0000000..d3937d3 --- /dev/null +++ b/src/main/java/com/challenge/api/service/notification/NewChallengeDTO.java @@ -0,0 +1,13 @@ +package com.challenge.api.service.notification; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class NewChallengeDTO { + + Long memberId; + String nickname; + +} diff --git a/src/main/java/com/challenge/api/service/notification/NotificationService.java b/src/main/java/com/challenge/api/service/notification/NotificationService.java new file mode 100644 index 0000000..5f36275 --- /dev/null +++ b/src/main/java/com/challenge/api/service/notification/NotificationService.java @@ -0,0 +1,62 @@ +package com.challenge.api.service.notification; + +import com.challenge.domain.member.Member; +import com.challenge.domain.member.MemberRepository; +import com.challenge.domain.notification.Notification; +import com.challenge.domain.notification.NotificationQueryRepository; +import com.challenge.domain.notification.NotificationRepository; +import com.challenge.exception.ErrorCode; +import com.challenge.exception.GlobalException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationQueryRepository notificationQueryRepository; + private final NotificationRepository notificationRepository; + private final MemberRepository memberRepository; + + /** + * 진행중인 챌린지가 없는 회원 token, 닉네임, id 조회 + * + * @return token, NewChallengeDTO + */ + public Map getNewChallengeTargets() { + return notificationQueryRepository.getNewChallengeTargets(); + } + + /** + * 현재 시각 기준 달성할 챌린지가 있는 회원 token, id, 닉네임, 챌린지 제목 리스트 조회 + * + * @return token, AchieveChallengeCountDTO + */ + public Map getAchieveTargetsAndChallenge() { + LocalDate today = LocalDate.now(); + return notificationQueryRepository.getAchieveTargetsAndChallenge(today); + } + + /** + * 알림 내역 생성 및 저장 + * + * @param memberId + * @param title + * @param content + * @return + */ + @Transactional + public Notification createAndSave(Long memberId, String title, String content) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + + return notificationRepository.save(Notification.of(title, content, LocalDateTime.now(), member)); + } + +} diff --git a/src/main/java/com/challenge/config/SchedulerConfig.java b/src/main/java/com/challenge/config/SchedulerConfig.java new file mode 100644 index 0000000..9fd91a4 --- /dev/null +++ b/src/main/java/com/challenge/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.challenge.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig { + +} diff --git a/src/main/java/com/challenge/domain/notification/NotificationQueryRepository.java b/src/main/java/com/challenge/domain/notification/NotificationQueryRepository.java new file mode 100644 index 0000000..d2ee59d --- /dev/null +++ b/src/main/java/com/challenge/domain/notification/NotificationQueryRepository.java @@ -0,0 +1,146 @@ +package com.challenge.domain.notification; + +import com.challenge.api.service.notification.AchieveChallengeDTO; +import com.challenge.api.service.notification.NewChallengeDTO; +import com.challenge.domain.challenge.ChallengeStatus; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.challenge.domain.challenge.QChallenge.challenge; +import static com.challenge.domain.challengeRecord.QChallengeRecord.challengeRecord; +import static com.challenge.domain.member.QMember.member; + +@RequiredArgsConstructor +@Repository +public class NotificationQueryRepository { + + private final JPAQueryFactory queryFactory; + + /** + * 진행중인 챌린지가 없는 회원 token 및 닉네임 조회 + * + * @return + */ + public Map getNewChallengeTargets() { + // ONGOING 상태인 challenge 개수가 0개인 member의 token, nickname 조회 + List result = queryFactory + .select(member.id, + member.fcmToken, + member.nickname) + .from(member) + .leftJoin(challenge) + .on(challenge.member.id.eq(member.id) + .and(challenge.status.eq(ChallengeStatus.ONGOING))) + .where(member.isNotificationReceived.eq(true)) + .groupBy(member.id, member.fcmToken, member.nickname) + .having(challenge.id.count().eq(0L)) + .fetch(); + + // 결과를 Map 형태로 변환 + Map resultMap = new HashMap<>(); + for (Tuple tuple : result) { + String token = tuple.get(member.fcmToken); + Long memberId = tuple.get(member.id); + String nickname = tuple.get(member.nickname); + + NewChallengeDTO dto = NewChallengeDTO.builder() + .memberId(memberId) + .nickname(nickname) + .build(); + resultMap.put(token, dto); + } + + return resultMap; + } + + /** + * 현재 시각 기준 달성할 챌린지가 있는 회원 token, 닉네임, 챌린지 제목 리스트 조회 + * + * @param day + * @return + */ + public Map getAchieveTargetsAndChallenge(LocalDate day) { + // status=ONGOING -> 진행중 + // 해당 챌린지의 마지막 기록이 없거나 isSucceed=false -> 달성 가능 + // 그 챌린지의 title, member.fcmToken, member.nickname 조회 + List result = queryFactory + .select(member.id, + member.fcmToken, + member.nickname, + challenge.title) + .from(challenge) + .join(member).on(challenge.member.id.eq(member.id)) + .where(challenge.status.eq(ChallengeStatus.ONGOING), + lastRecordSucceed(day).eq(false), + member.isNotificationReceived.eq(true)) + .fetch(); + + // 결과를 Map 형태로 변환 + Map resultMap = new HashMap<>(); + for (Tuple tuple : result) { + String token = tuple.get(member.fcmToken); + Long memberId = tuple.get(member.id); + String nickname = tuple.get(member.nickname); + String title = tuple.get(challenge.title); + + AchieveChallengeDTO dto = resultMap.getOrDefault( + token, + AchieveChallengeDTO.builder() + .memberId(memberId) + .nickname(nickname) + .challengeTitles(new ArrayList<>()) + .build()); + + dto.getChallengeTitles().add(title); + resultMap.put(token, dto); + } + + return resultMap; + } + + /** + * 해당 일자의 마지막 ChallengeRecord.isSucceeds를 반환, ChallengeRecord가 없는 경우 false를 반환 + * + * @param day 일자 + * @return + */ + private BooleanExpression lastRecordSucceed(LocalDate day) { + // 가징 최신 challengeRecord의 createdAt 조회 + Expression maxCreatedAtSubquery = + JPAExpressions.select(challengeRecord.createdAt.max()) + .from(challengeRecord) + .where(challengeRecord.challenge.id.eq(challenge.id), + challengeRecord.recordDate.eq(day)); + + // 가장 최신 challengeRecord의 isSucceed 값 조회 + JPQLQuery lastIsSucceedQuery = + JPAExpressions.select(challengeRecord.isSucceed) + .from(challengeRecord) + .where( + challengeRecord.challenge.id.eq(challenge.id), + challengeRecord.recordDate.eq(day), + challengeRecord.createdAt.eq(maxCreatedAtSubquery) // 가장 최신 createdAt + ); + + // null을 false로 처리 + return Expressions.booleanTemplate( + "COALESCE(({0}), FALSE)", + lastIsSucceedQuery + ); + } + +} diff --git a/src/main/java/com/challenge/scheduler/NotificationScheduler.java b/src/main/java/com/challenge/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..aa21e78 --- /dev/null +++ b/src/main/java/com/challenge/scheduler/NotificationScheduler.java @@ -0,0 +1,152 @@ +package com.challenge.scheduler; + +import com.challenge.api.service.fcm.FcmService; +import com.challenge.api.service.fcm.request.FcmMessage; +import com.challenge.api.service.notification.AchieveChallengeDTO; +import com.challenge.api.service.notification.NewChallengeDTO; +import com.challenge.api.service.notification.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Profile("dev") +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduler { + + private final NotificationService notificationService; + private final FcmService fcmService; + + private static final String NEW_CHALLENGE_TITLE = "⛳️ 챌린지를 등록해 보세요!"; + private static final String ACHIEVE_DAY_START_TITLE = "📬 오늘 달성할 수 있는 챌린지"; + private static final String ACHIEVE_DAY_END_TITLE = "🙌 달성할 수 있는 챌린지가 있어요!"; + private static final String NEW_CHALLENGE_BODY = "님, 지금 새 챌린지를 등록하고 달성할 수 있어요"; + private static final String ACHIEVE_DAY_START_BODY = "포함 %d개의 챌린지"; + private static final String ACHIEVE_DAY_END_BODY = "님, 아직 달성할 수 있는 챌린지가 %d개 있어요!"; + private static final int MAX_TITLES = 3; + + + /** + * 새로운 챌린지 등록 알림 발송 + */ + @Scheduled(cron = "0 0 9 * * *") + public void sendNewChallengeNotification() { + try { + // 알림 전송 대상 조회 + Map targetMap = notificationService.getNewChallengeTargets(); + + // 알림 객체 생성 + List fcmMessages = targetMap.entrySet().stream() + .map(entry -> + FcmMessage.of(entry.getKey(), NEW_CHALLENGE_TITLE, + entry.getValue().getNickname() + NEW_CHALLENGE_BODY) + ).toList(); + + // 알림 발송 및 내역 저장 + fcmMessages.forEach(message -> { + // 알림 발송 + fcmService.sendMessage(message); + + // 알림 내역 저장 + Long memberId = targetMap.get(message.getToken()).getMemberId(); + notificationService.createAndSave(memberId, message.getTitle(), message.getBody()); + + log.debug("New challenge notification sent: {}", message); + }); + } catch (Exception e) { + log.error("error occuerd while sending new challenge notification", e); + } + + } + + /** + * 챌린지 하루 시작 알림 발송 + */ + @Scheduled(cron = "0 0 9 * * *") + public void sendDayStartNotification() { + try { + // 알림 전송 대상 조회 + Map targetMap = notificationService.getAchieveTargetsAndChallenge(); + + // 알림 객체 생성 + List fcmMessages = targetMap.entrySet().stream() + .map(entry -> FcmMessage.of(entry.getKey(), ACHIEVE_DAY_START_TITLE, + getDayStartNotificationBody(entry.getValue())) + ).toList(); + + // 알림 발송 및 내역 저장 + fcmMessages.forEach(message -> { + // 알림 발송 + fcmService.sendMessage(message); + + // 알림 내역 저장 + Long memberId = targetMap.get(message.getToken()).getMemberId(); + notificationService.createAndSave(memberId, message.getTitle(), message.getBody()); + + log.debug("Day start notification sent: {}", message); + }); + } catch (Exception e) { + log.error("error occuerd while sending day start notification", e); + } + + } + + /** + * 챌린지 하루 종료 알림 발송 + */ + @Scheduled(cron = "0 0 21 * * *") + public void sendDayEndNotification() { + try { + // 알림 전송 대상 조회 + Map targetMap = notificationService.getAchieveTargetsAndChallenge(); + + // 알림 객체 생성 + List fcmMessages = targetMap.entrySet().stream() + .map(entry -> + FcmMessage.of(entry.getKey(), ACHIEVE_DAY_END_TITLE, + getDayEndNotificationBody(entry.getValue())) + ).toList(); + + // 알림 발송 및 내역 저장 + fcmMessages.forEach(message -> { + // 알림 발송 + fcmService.sendMessage(message); + + // 알림 내역 저장 + Long memberId = targetMap.get(message.getToken()).getMemberId(); + notificationService.createAndSave(memberId, message.getTitle(), message.getBody()); + + log.debug("Day end notification sent: {}", message); + }); + } catch (Exception e) { + log.error("error occuerd while sending day end notification", e); + } + + } + + private String getDayStartNotificationBody(AchieveChallengeDTO dto) { + List challengeTitles = dto.getChallengeTitles(); + String body = challengeTitles.stream() + .map(title -> "- " + title) + .collect(Collectors.joining("\n")); + + // 타이틀 개수가 3 이상인 경우 메시지 추가 + if (challengeTitles.size() >= MAX_TITLES) { + body += "\n" + String.format(ACHIEVE_DAY_START_BODY, challengeTitles.size()); + } + + return body; + } + + private String getDayEndNotificationBody(AchieveChallengeDTO dto) { + return dto.getNickname() + String.format(ACHIEVE_DAY_END_BODY, dto.getChallengeTitles().size()); + } + +} diff --git a/src/test/java/com/challenge/domain/notification/NotificationQueryRepositoryTest.java b/src/test/java/com/challenge/domain/notification/NotificationQueryRepositoryTest.java new file mode 100644 index 0000000..bc1a7d3 --- /dev/null +++ b/src/test/java/com/challenge/domain/notification/NotificationQueryRepositoryTest.java @@ -0,0 +1,320 @@ +package com.challenge.domain.notification; + +import com.challenge.api.service.notification.AchieveChallengeDTO; +import com.challenge.api.service.notification.NewChallengeDTO; +import com.challenge.domain.category.Category; +import com.challenge.domain.category.CategoryRepository; +import com.challenge.domain.challenge.Challenge; +import com.challenge.domain.challenge.ChallengeRepository; +import com.challenge.domain.challenge.ChallengeStatus; +import com.challenge.domain.challengeRecord.ChallengeRecord; +import com.challenge.domain.challengeRecord.ChallengeRecordRepository; +import com.challenge.domain.job.Job; +import com.challenge.domain.job.JobRepository; +import com.challenge.domain.member.Gender; +import com.challenge.domain.member.JobYear; +import com.challenge.domain.member.LoginType; +import com.challenge.domain.member.Member; +import com.challenge.domain.member.MemberRepository; +import com.challenge.utils.date.DateFormatter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +public class NotificationQueryRepositoryTest { + + @Autowired + private NotificationQueryRepository notificationQueryRepository; + + @Autowired + private ChallengeRepository challengeRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private ChallengeRecordRepository challengeRecordRepository; + + private Job job; + private Category category; + + @BeforeEach + void setUp() { + job = jobRepository.save( + Job.builder() + .code("1") + .description("1") + .build()); + + category = categoryRepository.save( + Category.builder() + .name("카테고리") + .build()); + } + + @AfterEach + void tearDown() { + challengeRecordRepository.deleteAllInBatch(); + challengeRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + jobRepository.deleteAllInBatch(); + } + + @Nested + @DisplayName("진행중인 챌린지가 없는 회원 조회") + class GetNewChallengeTargets { + + @DisplayName("회원의 챌린지 상태가 ONGOING인 경우 빈 map을 반환해야 한다.") + @Test + void getNewChallengeTargetsAllChallengesOngoing() { + // given + LocalDateTime dateTime = LocalDateTime.now().minusDays(1); + + Member member = createMember(1); + createChallenge(member, category, 1, "title1", ChallengeStatus.ONGOING, dateTime); + + // when + Map resultMap = notificationQueryRepository.getNewChallengeTargets(); + + // then + assertThat(resultMap).isEmpty(); + } + + @DisplayName("회원의 챌린지 상태가 ONGOING이 아니지만, 알림을 받지 않도록 설정한 경우 빈 map을 반환해야 한다.") + @Test + void getNewChallengeTargetsWhenNotReceiveNotification() { + // given + LocalDateTime dateTime = LocalDateTime.now().minusDays(1); + + Member member = createMember(1); + createChallenge(member, category, 1, "title1", ChallengeStatus.REMOVED, dateTime); + + // when + Map resultMap = notificationQueryRepository.getNewChallengeTargets(); + + // then + assertThat(resultMap).isEmpty(); + } + + @DisplayName("모든 회원의 챌린지 상태가 ONGOING이 아니고, 알림을 받도록 설정한 경우 회원 정보를 담은 map을 반환해야 한다.") + @Test + void getNewChallengeTargets() { + // given + LocalDateTime dateTime = LocalDateTime.now().minusDays(1); + + Member member1 = createMember(1); + member1.updateNotificationReceived(); + createChallenge(member1, category, 1, "title1", ChallengeStatus.REMOVED, dateTime); + + Member member2 = createMember(2); + member2.updateNotificationReceived(); + createChallenge(member2, category, 1, "title2", ChallengeStatus.SUCCEED, dateTime); + + Member member3 = createMember(3); + member3.updateNotificationReceived(); + createChallenge(member3, category, 1, "title3", ChallengeStatus.EXPIRED, dateTime); + + Member member4 = createMember(4); + member4.updateNotificationReceived(); + + memberRepository.saveAll(List.of(member1, member2, member3, member4)); + + // when + Map resultMap = notificationQueryRepository.getNewChallengeTargets(); + + // then + assertThat(resultMap).hasSize(4); + assertThat(resultMap).containsKeys("token1", "token2", "token3", "token4"); + } + + } + + @Nested + @DisplayName("달성할 챌린지가 있는 회원 및 챌린지 제목 조회") + class GetAchieveTargetsAndChallenge { + + @DisplayName("달성할 챌린지가 여러 개 있는 경우 dto의 챌린지 제목 리스트에 여러 개가 담긴 map을 반환해야 한다.") + @Test + void getAchieveTargetsAndChallengeWhenMultipleChallenges() { + // given + LocalDateTime dateTime = LocalDateTime.now(); + + Member member = createMember(1); + member.updateNotificationReceived(); + memberRepository.save(member); + + createChallenge(member, category, 1, "title1", ChallengeStatus.ONGOING, dateTime); + createChallenge(member, category, 1, "title2", ChallengeStatus.ONGOING, dateTime); + createChallenge(member, category, 1, "title3", ChallengeStatus.ONGOING, dateTime); + + // when + Map resultMap = notificationQueryRepository.getAchieveTargetsAndChallenge( + dateTime.toLocalDate()); + + // then + assertThat(resultMap).hasSize(1); + + AchieveChallengeDTO dto = resultMap.get("token1"); + assertThat(dto.getChallengeTitles()).hasSize(3); + assertThat(dto.getChallengeTitles()).containsExactly("title1", "title2", "title3"); + assertThat(dto.getNickname()).isEqualTo("nickname1"); + assertThat(dto.getMemberId()).isEqualTo(member.getId()); + } + + @DisplayName("챌린지의 오늘 일자 기록이 존재하지만 마지막 기록의 isSucceeds가 false인 경우 해당 챌린지 정보가 담긴 map을 반환해야 한다.") + @Test + void getAchieveTargetsAndChallengeWhenSingleChallenge() throws Exception { + // given + LocalDateTime dateTime = LocalDateTime.now(); + + // LocalDateTime -> String 변환 + String formattedDate = DateFormatter.LOCAL_DATE_FORMATTER.format(dateTime); + + Member member = createMember(1); + member.updateNotificationReceived(); + memberRepository.save(member); + + Challenge challenge = createChallenge(member, category, 1, "title1", ChallengeStatus.ONGOING, dateTime); + createRecord(challenge, dateTime.toLocalDate()); + challengeRecordRepository.save(ChallengeRecord.cancel(challenge, formattedDate)); + + // when + Map resultMap = notificationQueryRepository.getAchieveTargetsAndChallenge( + dateTime.toLocalDate()); + + // then + assertThat(resultMap).hasSize(1); + + AchieveChallengeDTO dto = resultMap.get("token1"); + assertThat(dto.getChallengeTitles()).hasSize(1); + assertThat(dto.getChallengeTitles()).containsExactly("title1"); + assertThat(dto.getNickname()).isEqualTo("nickname1"); + assertThat(dto.getMemberId()).isEqualTo(member.getId()); + } + + @DisplayName("회원의 챌린지 상태가 ONGOING이 아닌 경우 빈 map을 반환해야 한다.") + @Test + void getAchieveTargetsAndChallengeNotOngoing() { + // given + LocalDateTime dateTime = LocalDateTime.now(); + + Member member = createMember(1); + member.updateNotificationReceived(); + memberRepository.save(member); + + createChallenge(member, category, 1, "title1", ChallengeStatus.REMOVED, dateTime); + + // when + Map resultMap = notificationQueryRepository.getAchieveTargetsAndChallenge( + dateTime.toLocalDate()); + + // then + assertThat(resultMap).isEmpty(); + } + + @DisplayName("회원이 알림을 받지 않도록 설정한 경우 빈 map을 반환해야 한다.") + @Test + void getAchieveTargetsAndChallengeWhenNotReceiveNotification() { + // given + LocalDateTime dateTime = LocalDateTime.now(); + + Member member = createMember(1); + + createChallenge(member, category, 1, "title1", ChallengeStatus.ONGOING, dateTime); + + // when + Map resultMap = notificationQueryRepository.getAchieveTargetsAndChallenge( + dateTime.toLocalDate()); + + // then + assertThat(resultMap).isEmpty(); + } + + @DisplayName("챌린지 상태가 ONGOING이지만 오늘 일자에 이미 달성한 상태인 경우 빈 map을 반환해야 한다.") + @Test + void getAchieveTargetsAndChallengeWhenAlreadySucceed() { + // given + LocalDateTime dateTime = LocalDateTime.now(); + + // LocalDateTime -> String 변환 + String formattedDate = DateFormatter.LOCAL_DATE_FORMATTER.format(dateTime); + + Member member = createMember(1); + member.updateNotificationReceived(); + memberRepository.save(member); + + Challenge challenge = createChallenge(member, category, 1, "title1", ChallengeStatus.ONGOING, dateTime); + challengeRecordRepository.save(ChallengeRecord.achieve(challenge, formattedDate)); + + // when + Map resultMap = notificationQueryRepository.getAchieveTargetsAndChallenge( + dateTime.toLocalDate()); + + // then + assertThat(resultMap).isEmpty(); + } + + } + + + private Member createMember(Integer num) { + Member member = Member.builder() + .socialId(1L) + .email("eamil") + .loginType(LoginType.KAKAO) + .nickname("nickname" + num) + .birth(LocalDate.of(2000, 1, 1)) + .gender(Gender.MALE) + .jobYear(JobYear.LT_1Y) + .job(job) + .build(); + member.updateFcmToken("token" + num.toString()); + return memberRepository.save(member); + } + + private Challenge createChallenge(Member member, Category category, int durationInWeeks, String title, + ChallengeStatus status, LocalDateTime startDateTime) { + return challengeRepository.save( + Challenge.builder() + .member(member) + .category(category) + .durationInWeeks(durationInWeeks) + .title(title) + .color("#30B0C7") + .status(status) + .weeklyGoalCount(1) + .startDateTime(startDateTime) + .build()); + } + + private ChallengeRecord createRecord(Challenge challenge, LocalDate currentDate) { + return challengeRecordRepository.save( + ChallengeRecord.builder() + .challenge(challenge) + .recordDate(currentDate) + .isSucceed(true) + .build()); + } + +}