diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 7ada1cf..66492b6 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -31,6 +31,10 @@ jobs: mkdir -p src/main/resources/static echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8 + - name: Set Firebase Credentials + run: | + echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 80d8604..00c18d4 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -48,6 +48,10 @@ jobs: mkdir -p src/main/resources/static echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8 + - name: Set Firebase Credentials + run: | + echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b77eba2..6ecd45b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,10 @@ jobs: mkdir -p src/main/resources/static echo "${{ secrets.AUTH_KEY }}" > src/main/resources/static/AuthKey.p8 + - name: Set Firebase Credentials + run: | + echo "${{ secrets.FIREBASE_CREDENTIALS }}" | base64 --decode > src/main/resources/firebase.json + - name: Set up JDK 17 uses: actions/setup-java@v4 with: diff --git a/.gitignore b/.gitignore index 8fe4ec1..527681e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ src/main/resources/static/AuthKey.p8 src/main/resources/application-aws.yml ### Claude Code ### -.claude/ \ No newline at end of file +.claude/ + +### Firebase ### +src/main/resources/firebase.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index 61420e4..bf58a4c 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'software.amazon.awssdk:s3:2.31.16' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'com.google.firebase:firebase-admin:9.3.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' diff --git a/src/main/java/kr/swyp/backend/BackendApplication.java b/src/main/java/kr/swyp/backend/BackendApplication.java index 0decad5..4780a43 100644 --- a/src/main/java/kr/swyp/backend/BackendApplication.java +++ b/src/main/java/kr/swyp/backend/BackendApplication.java @@ -7,9 +7,11 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing @EnableFeignClients +@EnableScheduling @SpringBootApplication @ConfigurationPropertiesScan public class BackendApplication { diff --git a/src/main/java/kr/swyp/backend/common/config/FcmConfig.java b/src/main/java/kr/swyp/backend/common/config/FcmConfig.java new file mode 100644 index 0000000..27b3045 --- /dev/null +++ b/src/main/java/kr/swyp/backend/common/config/FcmConfig.java @@ -0,0 +1,63 @@ +package kr.swyp.backend.common.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import javax.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ClassPathResource; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FcmConfig { + + private final FcmProperties fcmProperties; + private final Environment environment; + + @PostConstruct + public void initialize() { + if (!fcmProperties.isEnabled()) { + log.info("FCM is disabled. Skipping initialization."); + return; + } + + // 테스트 프로파일에서는 초기화 건너뛰기 + String[] activeProfiles = environment.getActiveProfiles(); + for (String profile : activeProfiles) { + if ("test".equals(profile)) { + log.info("Test profile detected. Skipping FCM initialization."); + return; + } + } + + try { + if (FirebaseApp.getApps().isEmpty()) { + String credentialsPath = fcmProperties.getCredentialsPath(); + + // classpath 또는 파일 시스템 경로 지원 + try (InputStream serviceAccount = credentialsPath.startsWith("classpath:") + ? new ClassPathResource( + credentialsPath.substring("classpath:".length())).getInputStream() + : new FileInputStream(credentialsPath)) { + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + log.info("Firebase application has been initialized successfully."); + } + } + } catch (IOException e) { + log.error("Failed to initialize Firebase application.", e); + throw new RuntimeException("Failed to initialize FCM", e); + } + } +} diff --git a/src/main/java/kr/swyp/backend/common/config/FcmProperties.java b/src/main/java/kr/swyp/backend/common/config/FcmProperties.java new file mode 100644 index 0000000..99074ef --- /dev/null +++ b/src/main/java/kr/swyp/backend/common/config/FcmProperties.java @@ -0,0 +1,16 @@ +package kr.swyp.backend.common.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "swyp.fcm") +public class FcmProperties { + + private String credentialsPath; + private boolean enabled = true; +} diff --git a/src/main/java/kr/swyp/backend/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/kr/swyp/backend/common/exception/handler/GlobalExceptionHandler.java index 6b2029c..db1f062 100644 --- a/src/main/java/kr/swyp/backend/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/kr/swyp/backend/common/exception/handler/GlobalExceptionHandler.java @@ -45,6 +45,13 @@ public ResponseEntity handleIllegalArgumentException( return responseException("BAD_REQUEST", e.getMessage(), HttpStatus.BAD_REQUEST); } + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalStateException( + IllegalStateException e) { + log.info("잘못된 상태: {}", e.getMessage()); + return responseException("INVALID_STATE", e.getMessage(), HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(MethodNotAllowedException.class) public ResponseEntity handleMethodNotAllowedException( MethodNotAllowedException e) { diff --git a/src/main/java/kr/swyp/backend/friend/repository/FriendAnniversaryRepository.java b/src/main/java/kr/swyp/backend/friend/repository/FriendAnniversaryRepository.java index fbc7a1c..79e2d67 100644 --- a/src/main/java/kr/swyp/backend/friend/repository/FriendAnniversaryRepository.java +++ b/src/main/java/kr/swyp/backend/friend/repository/FriendAnniversaryRepository.java @@ -29,4 +29,15 @@ List findAllByFriendIdIsInAndDateBetween(List friendIds """) List findAllAnniversaryByCheckingLogIdList( @Param("checkingLogIdList") List checkingLogIdList); + + /** + * 오늘 날짜(월-일)의 기념일을 가진 항목들 조회 (알림 대상). + * 연도는 무시하고 월-일만 비교. + */ + @Query(""" + SELECT fa + FROM FriendAnniversary fa + WHERE MONTH(fa.date) = :month AND DAY(fa.date) = :day + """) + List findAllByMonthAndDay(@Param("month") int month, @Param("day") int day); } diff --git a/src/main/java/kr/swyp/backend/friend/repository/FriendRepository.java b/src/main/java/kr/swyp/backend/friend/repository/FriendRepository.java index 83bf6fd..90cd4c7 100644 --- a/src/main/java/kr/swyp/backend/friend/repository/FriendRepository.java +++ b/src/main/java/kr/swyp/backend/friend/repository/FriendRepository.java @@ -51,5 +51,10 @@ List findAllByMemberIdWithCheckedLogsInPeriod( void deleteAllByMemberId(UUID memberId); + /** + * 특정 날짜에 연락해야 할 친구 목록 조회 (알림 대상). + */ + List findAllByNextContactAt(LocalDate targetDate); + } diff --git a/src/main/java/kr/swyp/backend/member/domain/Member.java b/src/main/java/kr/swyp/backend/member/domain/Member.java index 7e88c1e..c8a4ad6 100644 --- a/src/main/java/kr/swyp/backend/member/domain/Member.java +++ b/src/main/java/kr/swyp/backend/member/domain/Member.java @@ -73,6 +73,10 @@ public class Member extends BaseEntity implements UserDetails { @Column(name = "WITHDRAWN_AT") private LocalDateTime withdrawnAt; + @Comment("FCM 토큰") + @Column(name = "FCM_TOKEN") + private String fcmToken; + @Default @OneToMany(mappedBy = "member", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) @@ -125,4 +129,8 @@ public void reactivate() { this.withdrawnAt = null; this.isActive = true; } + + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } } \ No newline at end of file diff --git a/src/main/java/kr/swyp/backend/messaging/service/AppPushMessagingServiceImpl.java b/src/main/java/kr/swyp/backend/messaging/service/AppPushMessagingServiceImpl.java index 7608b58..3d13765 100644 --- a/src/main/java/kr/swyp/backend/messaging/service/AppPushMessagingServiceImpl.java +++ b/src/main/java/kr/swyp/backend/messaging/service/AppPushMessagingServiceImpl.java @@ -24,6 +24,9 @@ public class AppPushMessagingServiceImpl implements AppPushMessagingService { @Override @Transactional public void registerDevice(UUID memberId, RegisterAppPushTokenRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다.")); + Optional maybeAppPushToken = appPushTokenRepository.findByMemberId(memberId); if (maybeAppPushToken.isEmpty()) { @@ -34,16 +37,17 @@ public void registerDevice(UUID memberId, RegisterAppPushTokenRequest request) { .build(); appPushTokenRepository.save(appPushToken); - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다.")); member.updateNotificationAgreedAt(LocalDateTime.now()); - memberRepository.save(member); } else { // 항목이 존재하면 토큰을 업데이트한다. AppPushToken appPushToken = maybeAppPushToken.get(); appPushToken.updateToken(request.getToken(), request.getOsType()); appPushTokenRepository.save(appPushToken); } + + // Member 엔티티의 fcmToken도 동기화 (NotificationScheduler에서 사용) + member.updateFcmToken(request.getToken()); + memberRepository.save(member); } @Override @@ -54,5 +58,7 @@ public void unregisterDevice(UUID memberId, UnregisterAppPushTokenRequest reques .orElseThrow(() -> new NoSuchElementException("해당 회원을 찾을 수 없습니다.")); member.updateNotificationAgreedAt(null); + member.updateFcmToken(null); + memberRepository.save(member); } } diff --git a/src/main/java/kr/swyp/backend/notification/controller/NotificationController.java b/src/main/java/kr/swyp/backend/notification/controller/NotificationController.java new file mode 100644 index 0000000..cccc6c9 --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/controller/NotificationController.java @@ -0,0 +1,67 @@ +package kr.swyp.backend.notification.controller; + +import jakarta.validation.Valid; +import java.util.Map; +import kr.swyp.backend.member.dto.MemberDetails; +import kr.swyp.backend.notification.dto.ForceSendNotificationRequest; +import kr.swyp.backend.notification.dto.SendTestNotificationRequest; +import kr.swyp.backend.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/notifications") +@PreAuthorize("hasAnyAuthority('USER')") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + /** + * 임시 테스트용 FCM 알림 전송 엔드포인트 + * JWT 토큰으로 인증된 사용자에게 FCM 푸시 알림을 전송합니다. + */ + @PostMapping("/test/send") + public ResponseEntity> sendTestNotification( + @AuthenticationPrincipal MemberDetails memberDetails, + @Valid @RequestBody SendTestNotificationRequest request) { + + notificationService.sendTestNotificationToMember( + memberDetails.getMemberId(), + request.getTitle(), + request.getBody() + ); + + return ResponseEntity.ok(Map.of( + "message", "FCM 알림이 성공적으로 전송되었습니다.", + "memberId", memberDetails.getMemberId().toString() + )); + } + + /** + * 특정 사용자에게 강제로 FCM 알림을 전송하는 엔드포인트 (관리자용). + * memberId를 직접 지정하여 해당 사용자에게 푸시 알림을 전송합니다. + */ + @PostMapping("/force/send") + public ResponseEntity> forceSendNotification( + @Valid @RequestBody ForceSendNotificationRequest request) { + + notificationService.forceSendNotification( + request.getMemberId(), + request.getTitle(), + request.getBody(), + request.getFriendId() + ); + + return ResponseEntity.ok(Map.of( + "message", "FCM 알림이 성공적으로 전송되었습니다.", + "memberId", request.getMemberId().toString() + )); + } +} diff --git a/src/main/java/kr/swyp/backend/notification/dto/FcmNotificationRequest.java b/src/main/java/kr/swyp/backend/notification/dto/FcmNotificationRequest.java new file mode 100644 index 0000000..906d4fa --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/dto/FcmNotificationRequest.java @@ -0,0 +1,17 @@ +package kr.swyp.backend.notification.dto; + +import java.util.Map; +import java.util.UUID; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FcmNotificationRequest { + + private String fcmToken; + private String title; + private String body; + private Map data; + private UUID friendId; +} diff --git a/src/main/java/kr/swyp/backend/notification/dto/ForceSendNotificationRequest.java b/src/main/java/kr/swyp/backend/notification/dto/ForceSendNotificationRequest.java new file mode 100644 index 0000000..78bc4b9 --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/dto/ForceSendNotificationRequest.java @@ -0,0 +1,27 @@ +package kr.swyp.backend.notification.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ForceSendNotificationRequest { + + @NotNull(message = "회원 ID는 필수입니다") + private UUID memberId; + + @NotBlank(message = "제목은 필수입니다") + private String title; + + @NotBlank(message = "내용은 필수입니다") + private String body; + + private UUID friendId; +} diff --git a/src/main/java/kr/swyp/backend/notification/dto/SendTestNotificationRequest.java b/src/main/java/kr/swyp/backend/notification/dto/SendTestNotificationRequest.java new file mode 100644 index 0000000..ef708be --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/dto/SendTestNotificationRequest.java @@ -0,0 +1,20 @@ +package kr.swyp.backend.notification.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SendTestNotificationRequest { + + @NotBlank(message = "제목은 필수입니다") + private String title; + + @NotBlank(message = "내용은 필수입니다") + private String body; +} diff --git a/src/main/java/kr/swyp/backend/notification/scheduler/NotificationScheduler.java b/src/main/java/kr/swyp/backend/notification/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..f049125 --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/scheduler/NotificationScheduler.java @@ -0,0 +1,109 @@ +package kr.swyp.backend.notification.scheduler; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import kr.swyp.backend.friend.domain.Friend; +import kr.swyp.backend.friend.domain.FriendAnniversary; +import kr.swyp.backend.friend.repository.FriendAnniversaryRepository; +import kr.swyp.backend.friend.repository.FriendRepository; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.notification.service.FcmService; +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 NotificationScheduler { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + private final FriendRepository friendRepository; + private final FriendAnniversaryRepository friendAnniversaryRepository; + private final MemberRepository memberRepository; + private final FcmService fcmService; + + /** + * 매일 오전 9시에 실행되는 친구 챙김 알림 스케줄러. + * Cron: 초 분 시 일 월 요일 + */ + @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") + @Transactional + public void sendDailyFriendReminders() { + log.info("Starting daily friend reminder notifications..."); + + LocalDate today = LocalDate.now(KOREA_ZONE); + Set processedFriendIds = new HashSet<>(); + + // 1. nextContactAt이 오늘인 친구들 처리 + List friendsToContact = friendRepository.findAllByNextContactAt(today); + log.info("Found {} friends to contact today based on nextContactAt", + friendsToContact.size()); + + for (Friend friend : friendsToContact) { + sendReminderForFriend(friend, "연락 예정일입니다."); + processedFriendIds.add(friend.getFriendId()); + } + + // 2. 오늘이 기념일인 친구들 처리 + int month = today.getMonthValue(); + int day = today.getDayOfMonth(); + List todayAnniversaries = + friendAnniversaryRepository.findAllByMonthAndDay(month, day); + log.info("Found {} anniversaries today", todayAnniversaries.size()); + + for (FriendAnniversary anniversary : todayAnniversaries) { + UUID friendId = anniversary.getFriendId(); + + // 이미 연락 예정일로 알림을 보낸 친구는 스킵 + if (processedFriendIds.contains(friendId)) { + continue; + } + + friendRepository.findById(friendId).ifPresent(friend -> { + String reason = "오늘은 " + anniversary.getTitle() + "입니다."; + sendReminderForFriend(friend, reason); + processedFriendIds.add(friendId); + }); + } + + log.info("Daily friend reminder notifications completed. Total notifications sent: {}", + processedFriendIds.size()); + } + + private void sendReminderForFriend(Friend friend, String reason) { + UUID memberId = friend.getMemberId(); + + memberRepository.findById(memberId).ifPresent(member -> { + // 알림 동의를 한 회원이고, FCM 토큰이 있는 경우에만 알림 전송 + if (member.getNotificationAgreedAt() != null && member.getFcmToken() != null + && !member.getFcmToken().isEmpty()) { + + fcmService.sendFriendReminder( + member.getFcmToken(), + friend.getFriendId(), + friend.getName(), + reason + ); + + // 알림 트리거 카운트 증가 + friend.updateAlarmTriggerCount(); + + log.debug("Sent reminder for friend {} to member {}", + friend.getName(), memberId); + } else { + log.debug("Skipped sending reminder for friend {} to member {} " + + "(no notification consent or FCM token)", + friend.getName(), memberId); + } + }); + } +} diff --git a/src/main/java/kr/swyp/backend/notification/service/FcmService.java b/src/main/java/kr/swyp/backend/notification/service/FcmService.java new file mode 100644 index 0000000..65dd2f2 --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/service/FcmService.java @@ -0,0 +1,17 @@ +package kr.swyp.backend.notification.service; + +import java.util.UUID; +import kr.swyp.backend.notification.dto.FcmNotificationRequest; + +public interface FcmService { + + /** + * FCM 푸시 알림 전송. + */ + void sendNotification(FcmNotificationRequest request); + + /** + * 친구 챙김 알림 전송. + */ + void sendFriendReminder(String fcmToken, UUID friendId, String friendName, String reason); +} diff --git a/src/main/java/kr/swyp/backend/notification/service/FcmServiceImpl.java b/src/main/java/kr/swyp/backend/notification/service/FcmServiceImpl.java new file mode 100644 index 0000000..3ad88ed --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/service/FcmServiceImpl.java @@ -0,0 +1,102 @@ +package kr.swyp.backend.notification.service; + +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import kr.swyp.backend.common.config.FcmProperties; +import kr.swyp.backend.notification.dto.FcmNotificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmServiceImpl implements FcmService { + + private final FcmProperties fcmProperties; + + @Override + public void sendNotification(FcmNotificationRequest request) { + if (!fcmProperties.isEnabled()) { + log.debug("FCM is disabled. Skipping notification."); + return; + } + + try { + Message.Builder messageBuilder = Message.builder() + .setToken(request.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()); + + // 데이터 페이로드 추가 + if (request.getData() != null && !request.getData().isEmpty()) { + messageBuilder.putAllData(request.getData()); + } + + // Friend ID 추가 (iOS에서 알림 클릭 시 친구 상세 페이지로 이동에 사용) + if (request.getFriendId() != null) { + messageBuilder.putData("friendId", request.getFriendId().toString()); + } + + // body를 data 필드에도 추가 (iOS 요구사항) + messageBuilder.putData("body", request.getBody()); + + // date 추가 (iOS 요구사항 - ISO8601 형식) + String dateString = ZonedDateTime.now() + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + messageBuilder.putData("date", dateString); + + // iOS APNS 설정 (sound, badge) + ApnsConfig apnsConfig = ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(1) + .build()) + .build(); + messageBuilder.setApnsConfig(apnsConfig); + + Message message = messageBuilder.build(); + String response = FirebaseMessaging.getInstance().send(message); + + log.info("Successfully sent FCM notification. Response: {}", response); + } catch (FirebaseMessagingException e) { + log.error("Failed to send FCM notification to token: {}", request.getFcmToken(), e); + } + } + + @Override + public void sendFriendReminder(String fcmToken, UUID friendId, String friendName, + String reason) { + if (fcmToken == null || fcmToken.isEmpty()) { + log.debug("FCM token is empty. Skipping notification."); + return; + } + + Map data = new HashMap<>(); + data.put("type", "FRIEND_REMINDER"); + data.put("reason", reason); + + String body = friendName + "님과 연락할 시간이에요!"; + + FcmNotificationRequest request = FcmNotificationRequest.builder() + .fcmToken(fcmToken) + .title("친구 챙기기") + .body(body) + .friendId(friendId) + .data(data) + .build(); + + sendNotification(request); + } +} diff --git a/src/main/java/kr/swyp/backend/notification/service/NotificationService.java b/src/main/java/kr/swyp/backend/notification/service/NotificationService.java new file mode 100644 index 0000000..da32614 --- /dev/null +++ b/src/main/java/kr/swyp/backend/notification/service/NotificationService.java @@ -0,0 +1,85 @@ +package kr.swyp.backend.notification.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.notification.dto.FcmNotificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final MemberRepository memberRepository; + private final FcmService fcmService; + + /** + * 특정 회원에게 테스트 FCM 알림을 전송합니다. + * + * @param memberId 회원 ID + * @param title 알림 제목 + * @param body 알림 내용 + */ + @Transactional(readOnly = true) + public void sendTestNotificationToMember(UUID memberId, String title, String body) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + if (member.getFcmToken() == null || member.getFcmToken().isEmpty()) { + log.warn("Member {} does not have FCM token", memberId); + throw new IllegalStateException("FCM 토큰이 등록되지 않았습니다."); + } + + Map data = new HashMap<>(); + data.put("type", "TEST_NOTIFICATION"); + + FcmNotificationRequest request = FcmNotificationRequest.builder() + .fcmToken(member.getFcmToken()) + .title(title) + .body(body) + .data(data) + .build(); + + fcmService.sendNotification(request); + log.info("Test notification sent to member: {}", memberId); + } + + /** + * 특정 회원에게 강제로 FCM 알림을 전송합니다 (관리자용). + * + * @param memberId 회원 ID + * @param title 알림 제목 + * @param body 알림 내용 + * @param friendId 친구 ID (선택사항, 알림 클릭 시 친구 상세 페이지로 이동) + */ + @Transactional(readOnly = true) + public void forceSendNotification(UUID memberId, String title, String body, UUID friendId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")); + + if (member.getFcmToken() == null || member.getFcmToken().isEmpty()) { + log.warn("Member {} does not have FCM token", memberId); + throw new IllegalStateException("FCM 토큰이 등록되지 않았습니다."); + } + + Map data = new HashMap<>(); + data.put("type", "FORCE_NOTIFICATION"); + + FcmNotificationRequest request = FcmNotificationRequest.builder() + .fcmToken(member.getFcmToken()) + .title(title) + .body(body) + .friendId(friendId) + .data(data) + .build(); + + fcmService.sendNotification(request); + log.info("Force notification sent to member: {}, friendId: {}", memberId, friendId); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 2dafe88..174a8fb 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,4 +29,7 @@ swyp: jwt: secret: fJm76UJ3g8JL5P9tM3vUZbpuNDKVwGD8RQeMbwu2T15iZpvTM0LQCqERiXqK4Xc0 access-token-validity-in-milli-seconds: 86400000 # 1일 - refresh-token-validity-in-days: 7 # 7일 \ No newline at end of file + refresh-token-validity-in-days: 7 # 7일 + fcm: + enabled: true + credentials-path: classpath:firebase.json \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 6b3973e..a1f590f 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -35,4 +35,7 @@ swyp: region-name: ap-northeast-2 bucket-name: test-bucket access-key: test - secret-key: test \ No newline at end of file + secret-key: test + fcm: + enabled: false + credentials-path: classpath:firebase-test.json \ No newline at end of file diff --git a/src/test/java/kr/swyp/backend/notification/controller/NotificationControllerTest.java b/src/test/java/kr/swyp/backend/notification/controller/NotificationControllerTest.java new file mode 100644 index 0000000..808b181 --- /dev/null +++ b/src/test/java/kr/swyp/backend/notification/controller/NotificationControllerTest.java @@ -0,0 +1,419 @@ +package kr.swyp.backend.notification.controller; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.authentication.provider.TokenProvider; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.dto.MemberDetails; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import kr.swyp.backend.notification.dto.ForceSendNotificationRequest; +import kr.swyp.backend.notification.dto.SendTestNotificationRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class NotificationControllerTest { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String AUTHORIZATION_VALUE_PREFIX = "Bearer "; + + private final String url = "/notifications"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + private Member testMember; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 (FCM 토큰 포함) + testMember = memberRepository.save( + Member.builder() + .username("testuser@example.com") + .password("encoded_password") + .nickname("테스트유저") + .isActive(true) + .fcmToken("test-fcm-token-12345") + .build() + ); + + // 역할 추가 + testMember.addRole(RoleType.USER); + } + + @Test + @DisplayName("JWT 인증된 사용자에게 테스트 FCM 알림을 전송할 수 있어야 한다.") + void JWT_인증된_사용자에게_테스트_FCM_알림을_전송할_수_있어야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + SendTestNotificationRequest request = SendTestNotificationRequest.builder() + .title("테스트 알림") + .body("이것은 테스트 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/test/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("FCM 알림이 성공적으로 전송되었습니다.")) + .andExpect(jsonPath("$.memberId").value(memberId.toString())); + + // docs + result.andDo(document("테스트 FCM 알림 전송", + "JWT 토큰으로 인증된 사용자에게 테스트 FCM 푸시 알림을 전송한다.", + "테스트 FCM 알림 전송", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT 토큰")), + requestFields( + fieldWithPath("title").description("알림 제목"), + fieldWithPath("body").description("알림 내용") + ), + responseFields( + fieldWithPath("message").description("알림 전송 완료 메시지"), + fieldWithPath("memberId").description("알림을 받은 회원 ID") + ))); + } + + @Test + @DisplayName("FCM 토큰이 없는 사용자는 알림 전송에 실패해야 한다.") + void FCM_토큰이_없는_사용자는_알림_전송에_실패해야_한다() throws Exception { + // given + // FCM 토큰이 없는 회원 생성 + Member memberWithoutFcm = memberRepository.save( + Member.builder() + .username("nofcm@example.com") + .password("encoded_password") + .nickname("FCM없는유저") + .isActive(true) + .build() + ); + memberWithoutFcm.addRole(RoleType.USER); + + UUID memberId = memberWithoutFcm.getMemberId(); + String accessToken = createAccessToken(memberId); + + SendTestNotificationRequest request = SendTestNotificationRequest.builder() + .title("테스트 알림") + .body("이것은 테스트 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/test/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_STATE")) + .andExpect(jsonPath("$.message").value("FCM 토큰이 등록되지 않았습니다.")); + } + + @Test + @DisplayName("제목이 없으면 알림 전송에 실패해야 한다.") + void 제목이_없으면_알림_전송에_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + SendTestNotificationRequest request = SendTestNotificationRequest.builder() + .title("") // 빈 제목 + .body("이것은 테스트 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/test/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("본문이 없으면 알림 전송에 실패해야 한다.") + void 본문이_없으면_알림_전송에_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + SendTestNotificationRequest request = SendTestNotificationRequest.builder() + .title("테스트 알림") + .body("") // 빈 본문 + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/test/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("특정 사용자에게 강제로 FCM 알림을 전송할 수 있어야 한다.") + void 특정_사용자에게_강제로_FCM_알림을_전송할_수_있어야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + UUID friendId = UUID.randomUUID(); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(memberId) + .title("강제 알림 테스트") + .body("이것은 강제 알림입니다.") + .friendId(friendId) + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("FCM 알림이 성공적으로 전송되었습니다.")) + .andExpect(jsonPath("$.memberId").value(memberId.toString())); + + // docs + result.andDo(document("강제 FCM 알림 전송", + "특정 사용자에게 강제로 FCM 푸시 알림을 전송한다. (관리자용)", + "강제 FCM 알림 전송", + false, + false, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).description("발급받은 JWT 토큰")), + requestFields( + fieldWithPath("memberId").description("알림을 받을 회원 ID"), + fieldWithPath("title").description("알림 제목"), + fieldWithPath("body").description("알림 내용"), + fieldWithPath("friendId").description("친구 ID (선택사항, 알림 클릭 시 친구 상세 페이지로 이동)").optional() + ), + responseFields( + fieldWithPath("message").description("알림 전송 완료 메시지"), + fieldWithPath("memberId").description("알림을 받은 회원 ID") + ))); + } + + @Test + @DisplayName("friendId 없이도 강제 FCM 알림을 전송할 수 있어야 한다.") + void friendId_없이도_강제_FCM_알림을_전송할_수_있어야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(memberId) + .title("강제 알림 테스트") + .body("이것은 강제 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("FCM 알림이 성공적으로 전송되었습니다.")) + .andExpect(jsonPath("$.memberId").value(memberId.toString())); + } + + @Test + @DisplayName("존재하지 않는 회원에게는 강제 알림 전송이 실패해야 한다.") + void 존재하지_않는_회원에게는_강제_알림_전송이_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + UUID nonExistentMemberId = UUID.randomUUID(); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(nonExistentMemberId) + .title("강제 알림 테스트") + .body("이것은 강제 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("회원을 찾을 수 없습니다.")); + } + + @Test + @DisplayName("FCM 토큰이 없는 회원에게는 강제 알림 전송이 실패해야 한다.") + void FCM_토큰이_없는_회원에게는_강제_알림_전송이_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + Member memberWithoutFcm = memberRepository.save( + Member.builder() + .username("nofcm2@example.com") + .password("encoded_password") + .nickname("FCM없는유저2") + .isActive(true) + .build() + ); + memberWithoutFcm.addRole(RoleType.USER); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(memberWithoutFcm.getMemberId()) + .title("강제 알림 테스트") + .body("이것은 강제 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_STATE")) + .andExpect(jsonPath("$.message").value("FCM 토큰이 등록되지 않았습니다.")); + } + + @Test + @DisplayName("강제 알림 전송 시 제목이 없으면 실패해야 한다.") + void 강제_알림_전송_시_제목이_없으면_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(memberId) + .title("") + .body("이것은 강제 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("강제 알림 전송 시 본문이 없으면 실패해야 한다.") + void 강제_알림_전송_시_본문이_없으면_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(memberId) + .title("강제 알림 테스트") + .body("") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("강제 알림 전송 시 회원 ID가 없으면 실패해야 한다.") + void 강제_알림_전송_시_회원_ID가_없으면_실패해야_한다() throws Exception { + // given + UUID memberId = testMember.getMemberId(); + String accessToken = createAccessToken(memberId); + + ForceSendNotificationRequest request = ForceSendNotificationRequest.builder() + .memberId(null) + .title("강제 알림 테스트") + .body("이것은 강제 알림입니다.") + .build(); + + // when + ResultActions result = mockMvc.perform(post(url + "/force/send") + .header(AUTHORIZATION_HEADER, AUTHORIZATION_VALUE_PREFIX + accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()); + } + + private String createAccessToken(UUID memberId) { + List authorities = Collections.singletonList( + new SimpleGrantedAuthority(RoleType.USER.name())); + MemberDetails memberDetails = new MemberDetails(memberId, "test", "", authorities); + Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, "", + authorities); + return tokenProvider.generateAccessToken(authentication); + } +} diff --git a/src/test/java/kr/swyp/backend/notification/scheduler/NotificationSchedulerTest.java b/src/test/java/kr/swyp/backend/notification/scheduler/NotificationSchedulerTest.java new file mode 100644 index 0000000..b5f56a8 --- /dev/null +++ b/src/test/java/kr/swyp/backend/notification/scheduler/NotificationSchedulerTest.java @@ -0,0 +1,225 @@ +package kr.swyp.backend.notification.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.TimeZone; +import kr.swyp.backend.friend.domain.Friend; +import kr.swyp.backend.friend.domain.FriendAnniversary; +import kr.swyp.backend.friend.domain.FriendContactFrequency; +import kr.swyp.backend.friend.enums.FriendContactWeek; +import kr.swyp.backend.friend.repository.FriendAnniversaryRepository; +import kr.swyp.backend.friend.repository.FriendRepository; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class NotificationSchedulerTest { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + @BeforeAll + static void setUpTimezone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + @Autowired + private NotificationScheduler notificationScheduler; + + @Autowired + private FriendRepository friendRepository; + + @Autowired + private FriendAnniversaryRepository friendAnniversaryRepository; + + @Autowired + private MemberRepository memberRepository; + + private Member testMember; + private Friend testFriend; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 (알림 동의 + FCM 토큰 있음) + testMember = Member.builder() + .username("test@test.com") + .nickname("테스트유저") + .password(" ") + .isActive(true) + .notificationAgreedAt(LocalDateTime.now()) + .fcmToken("test-fcm-token-123") + .build(); + testMember.addRole(RoleType.USER); + testMember = memberRepository.save(testMember); + + // 테스트용 친구 생성 + testFriend = Friend.builder() + .memberId(testMember.getMemberId()) + .name("친구1") + .contactFrequency(FriendContactFrequency.builder() + .contactWeek(FriendContactWeek.EVERY_WEEK) + .build()) + .position(1) + .nextContactAt(LocalDate.now()) + .checkRate(0) + .alarmTriggerCount(0) + .build(); + testFriend = friendRepository.save(testFriend); + } + + @Test + @DisplayName("nextContactAt이 오늘인 친구에 대해 알림이 발송되어야 한다") + void nextContactAt이_오늘인_친구에_대해_알림이_발송되어야_한다() { + // given + LocalDate today = LocalDate.now(); + testFriend = Friend.builder() + .memberId(testMember.getMemberId()) + .name("오늘 연락할 친구") + .contactFrequency(FriendContactFrequency.builder() + .contactWeek(FriendContactWeek.EVERY_WEEK) + .build()) + .position(1) + .nextContactAt(today) + .checkRate(0) + .alarmTriggerCount(0) + .build(); + testFriend = friendRepository.save(testFriend); + + int initialTriggerCount = testFriend.getAlarmTriggerCount(); + + // when + notificationScheduler.sendDailyFriendReminders(); + + // then + Friend updatedFriend = friendRepository.findById(testFriend.getFriendId()).orElseThrow(); + assertThat(updatedFriend.getAlarmTriggerCount()).isEqualTo(initialTriggerCount + 1); + } + + @Test + @DisplayName("연락 예정일과 기념일이 겹치는 경우 알림은 한 번만 발송되어야 한다") + void 연락_예정일과_기념일이_겹치는_경우_알림은_한_번만_발송되어야_한다() { + // given + LocalDate today = LocalDate.now(); + + Friend friend = Friend.builder() + .memberId(testMember.getMemberId()) + .name("겹치는 친구") + .contactFrequency(FriendContactFrequency.builder() + .contactWeek(FriendContactWeek.EVERY_WEEK) + .build()) + .position(1) + .nextContactAt(today) // 오늘 연락 예정 + .checkRate(0) + .alarmTriggerCount(0) + .build(); + friend = friendRepository.save(friend); + + FriendAnniversary anniversary = FriendAnniversary.builder() + .friendId(friend.getFriendId()) + .title("생일") + .date(today) // 오늘이 기념일 + .build(); + friendAnniversaryRepository.save(anniversary); + + int initialTriggerCount = friend.getAlarmTriggerCount(); + + // when + notificationScheduler.sendDailyFriendReminders(); + + // then + Friend updatedFriend = friendRepository.findById(friend.getFriendId()).orElseThrow(); + // 알림은 한 번만 발송되므로 카운트는 1만 증가 + assertThat(updatedFriend.getAlarmTriggerCount()).isEqualTo(initialTriggerCount + 1); + } + + @Test + @DisplayName("FCM 토큰이 없는 회원에게는 알림이 발송되지 않아야 한다") + void FCM_토큰이_없는_회원에게는_알림이_발송되지_않아야_한다() { + // given + Member memberWithoutToken = Member.builder() + .username("notoken@test.com") + .nickname("토큰없음") + .password(" ") + .isActive(true) + .notificationAgreedAt(LocalDateTime.now()) + .fcmToken(null) // FCM 토큰 없음 + .build(); + memberWithoutToken.addRole(RoleType.USER); + memberWithoutToken = memberRepository.save(memberWithoutToken); + + Friend friend = Friend.builder() + .memberId(memberWithoutToken.getMemberId()) + .name("토큰없는 회원의 친구") + .contactFrequency(FriendContactFrequency.builder() + .contactWeek(FriendContactWeek.EVERY_WEEK) + .build()) + .position(1) + .nextContactAt(LocalDate.now()) + .checkRate(0) + .alarmTriggerCount(0) + .build(); + friend = friendRepository.save(friend); + + int initialTriggerCount = friend.getAlarmTriggerCount(); + + // when + notificationScheduler.sendDailyFriendReminders(); + + // then + Friend updatedFriend = friendRepository.findById(friend.getFriendId()).orElseThrow(); + // 알림이 발송되지 않으므로 카운트가 증가하지 않음 + assertThat(updatedFriend.getAlarmTriggerCount()).isEqualTo(initialTriggerCount); + } + + @Test + @DisplayName("알림 동의하지 않은 회원에게는 알림이 발송되지 않아야 한다") + void 알림_동의하지_않은_회원에게는_알림이_발송되지_않아야_한다() { + // given + Member memberWithoutConsent = Member.builder() + .username("noconsent@test.com") + .nickname("동의안함") + .password(" ") + .isActive(true) + .notificationAgreedAt(null) // 알림 동의 안 함 + .fcmToken("test-fcm-token-456") + .build(); + memberWithoutConsent.addRole(RoleType.USER); + memberWithoutConsent = memberRepository.save(memberWithoutConsent); + + Friend friend = Friend.builder() + .memberId(memberWithoutConsent.getMemberId()) + .name("동의안한 회원의 친구") + .contactFrequency(FriendContactFrequency.builder() + .contactWeek(FriendContactWeek.EVERY_WEEK) + .build()) + .position(1) + .nextContactAt(LocalDate.now()) + .checkRate(0) + .alarmTriggerCount(0) + .build(); + friend = friendRepository.save(friend); + + int initialTriggerCount = friend.getAlarmTriggerCount(); + + // when + notificationScheduler.sendDailyFriendReminders(); + + // then + Friend updatedFriend = friendRepository.findById(friend.getFriendId()).orElseThrow(); + // 알림이 발송되지 않으므로 카운트가 증가하지 않음 + assertThat(updatedFriend.getAlarmTriggerCount()).isEqualTo(initialTriggerCount); + } +} diff --git a/src/test/java/kr/swyp/backend/notification/service/NotificationServiceTest.java b/src/test/java/kr/swyp/backend/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..38f2c86 --- /dev/null +++ b/src/test/java/kr/swyp/backend/notification/service/NotificationServiceTest.java @@ -0,0 +1,133 @@ +package kr.swyp.backend.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.time.LocalDateTime; +import java.util.UUID; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class NotificationServiceTest { + + @Autowired + private NotificationService notificationService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("특정 회원에게 강제 알림을 전송할 수 있어야 한다.") + void 특정_회원에게_강제_알림을_전송할_수_있어야_한다() { + // given + Member member = createMemberWithFcmToken("test@test.com", "테스트유저", "test-fcm-token"); + String title = "테스트 제목"; + String body = "테스트 내용"; + UUID friendId = UUID.randomUUID(); + + // when + Throwable throwable = catchThrowable( + () -> notificationService.forceSendNotification( + member.getMemberId(), title, body, friendId)); + + // then + // FCM이 비활성화된 테스트 환경에서는 예외 없이 정상 처리되어야 함 + assertThat(throwable).isNull(); + } + + @Test + @DisplayName("friendId 없이도 강제 알림을 전송할 수 있어야 한다.") + void friendId_없이도_강제_알림을_전송할_수_있어야_한다() { + // given + Member member = createMemberWithFcmToken("test@test.com", "테스트유저", "test-fcm-token"); + String title = "테스트 제목"; + String body = "테스트 내용"; + + // when + Throwable throwable = catchThrowable( + () -> notificationService.forceSendNotification( + member.getMemberId(), title, body, null)); + + // then + assertThat(throwable).isNull(); + } + + @Test + @DisplayName("FCM 토큰이 없는 회원에게는 강제 알림 전송이 실패해야 한다.") + void FCM_토큰이_없는_회원에게는_강제_알림_전송이_실패해야_한다() { + // given + Member member = createMemberWithFcmToken("test@test.com", "테스트유저", null); + String title = "테스트 제목"; + String body = "테스트 내용"; + + // when + Throwable throwable = catchThrowable( + () -> notificationService.forceSendNotification( + member.getMemberId(), title, body, null)); + + // then + assertThat(throwable).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("FCM 토큰이 등록되지 않았습니다."); + } + + @Test + @DisplayName("빈 FCM 토큰을 가진 회원에게는 강제 알림 전송이 실패해야 한다.") + void 빈_FCM_토큰을_가진_회원에게는_강제_알림_전송이_실패해야_한다() { + // given + Member member = createMemberWithFcmToken("test@test.com", "테스트유저", ""); + String title = "테스트 제목"; + String body = "테스트 내용"; + + // when + Throwable throwable = catchThrowable( + () -> notificationService.forceSendNotification( + member.getMemberId(), title, body, null)); + + // then + assertThat(throwable).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("FCM 토큰이 등록되지 않았습니다."); + } + + @Test + @DisplayName("존재하지 않는 회원에게는 강제 알림 전송이 실패해야 한다.") + void 존재하지_않는_회원에게는_강제_알림_전송이_실패해야_한다() { + // given + UUID nonExistentMemberId = UUID.randomUUID(); + String title = "테스트 제목"; + String body = "테스트 내용"; + + // when + Throwable throwable = catchThrowable( + () -> notificationService.forceSendNotification( + nonExistentMemberId, title, body, null)); + + // then + assertThat(throwable).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("회원을 찾을 수 없습니다."); + } + + private Member createMemberWithFcmToken(String username, String nickname, String fcmToken) { + Member member = Member.builder() + .username(username) + .nickname(nickname) + .password(" ") + .isActive(true) + .notificationAgreedAt(LocalDateTime.now()) + .fcmToken(fcmToken) + .build(); + + member.addRole(RoleType.USER); + + return memberRepository.save(member); + } +}