diff --git a/docs/TESTING_GUIDELINES.md b/docs/TESTING_GUIDELINES.md index 1ed7837f..244261e5 100644 --- a/docs/TESTING_GUIDELINES.md +++ b/docs/TESTING_GUIDELINES.md @@ -336,14 +336,21 @@ void 책_평점_계산이_올바르게_동작한다() { - **최소 데이터**: 테스트에 필요한 최소한의 속성만 설정 ### 3. 테스트 독립성 + +테스트는 실행 순서에 의존하지 않아야 하며, 이전 테스트의 상태가 남아있지 않도록 격리해야 합니다. + ```java @BeforeEach void setUp() { - // 각 테스트마다 깨끗한 상태로 시작 - databaseCleaner.clear(); + + // 1. CircuitBreakerReset: 이전 테스트의 영향(Open/Half-Open) 제거 + // 핵심: 이전 테스트의 영향도를 삭제하고 '깨끗한 상태'에서 시작 + circuitBreakerRegistry.circuitBreaker("fcm").transitionToClosedState(); } ``` +> **Note**: DB 초기화(`databaseCleaner.clear()`)는 `@IntegrationTest` 및 `@CustomRepositoryTest`에 등록된 `CleanDatabaseBeforeEach` 확장에 의해 **자동으로 수행**되므로 별도로 호출할 필요가 없습니다. 또한, 이때 `BookCategory` 테이블의 **ROOT, NULL** 엔티티와 같은 필수 기초 데이터도 자동으로 복구됩니다. + ### 4. Fixture를 활용한 의미있는 테스트 데이터 Fixture는 4가지 메서드 패턴을 제공하며, 상황에 맞게 선택하여 사용합니다: @@ -417,6 +424,36 @@ Member member = new Member("user123", "test@test.com"); - **도메인 규칙 보장**: 모든 메서드가 유효한 도메인 객체 생성 - **테스트 격리**: 각 테스트마다 독립적인 랜덤 데이터 사용 - **최소 속성 원칙**: builder 사용 시 테스트에 꼭 필요한 속성만 명시적으로 설정하고, 나머지는 Fixture 기본값 사용 +427: +428: ### 5. 시간 의존성 테스트 +429: +430: `LocalDateTime.now()` 등을 내부에서 직접 호출하면 미래/과거 시점 테스트가 어렵습니다. 시간을 파라미터로 받거나 `Clock` Bean을 사용하여 테스트 가능성을 높이세요. +431: +432: ```java +433: // ❌ 테스트하기 어려움 - 내부에서 현재 시간 고정 +434: public void processExpired() { +435: LocalDateTime now = LocalDateTime.now(); // 테스트에서 제어 불가 +436: // ... +437: } +438: +439: // ✅ 테스트하기 쉬움 - 시간을 파라미터로 주입 +440: public void processExpired(LocalDateTime currentTime) { +441: // currentTime 기준으로 로직 수행 +442: } +443: +444: // ✅ 테스트 코드 예시 +445: @Test +446: void 만료된_항목을_처리한다() { +447: // given: 1시간 뒤 미래 시간을 주입하여 만료 조건 충족 +448: LocalDateTime futureTime = LocalDateTime.now().plusHours(1); +449: +450: // when +451: service.processExpired(futureTime); +452: +453: // then +454: // ... +455: } +456: ``` ## 🚫 안티패턴 diff --git a/src/main/java/book/book/common/ErrorCode.java b/src/main/java/book/book/common/ErrorCode.java index c39ef55a..30c7ebc5 100644 --- a/src/main/java/book/book/common/ErrorCode.java +++ b/src/main/java/book/book/common/ErrorCode.java @@ -94,8 +94,9 @@ public enum ErrorCode { // N0xx: 알림 관련 예외 NOT_FOUND_FCMTOKEN("N000", "본 유저의 fcm토큰이 저장되어 있지 않습니다.", HttpStatus.NOT_FOUND), NOTIFICATION_NOT_FOUND("N001", "알림을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - NOTIFICATION_METADATA_CONVERSION_ERROR("N002", "알림 메타데이터 JSON 변환에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - FCM_SEND_ERROR("N003", "FCM 알림 전송 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + NOTIFICATION_LOG_NOT_FOUND("N002", "알림 전송 로그를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + NOTIFICATION_METADATA_CONVERSION_ERROR("N003", "알림 메타데이터 JSON 변환에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + FCM_SEND_ERROR("N004", "FCM 알림 전송 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), // B10xx: follow 예외 FOLLOW_AREADY_EXIST("B1001", "이미 팔로우 한 유저입니다.", HttpStatus.CONFLICT), diff --git a/src/main/java/book/book/config/Resilience4jConfig.java b/src/main/java/book/book/config/Resilience4jConfig.java new file mode 100644 index 00000000..d5037561 --- /dev/null +++ b/src/main/java/book/book/config/Resilience4jConfig.java @@ -0,0 +1,53 @@ +package book.book.config; + +import book.book.notification.exception.FcmInvalidTokenException; +import book.book.notification.exception.FcmRetryableException; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class Resilience4jConfig { + + @Bean + public RetryRegistry retryRegistry() { + RetryConfig fcmRetryConfig = RetryConfig.custom() + .maxAttempts(4) // 첫 시도 포함 총 4회 (재시도 3회) + .intervalFunction(IntervalFunction.ofExponentialBackoff( + Duration.ofSeconds(1), // 초기 1초 + 2.0 // 1s -> 2s -> 4s + )) + .retryExceptions(FcmRetryableException.class, IOException.class) + .ignoreExceptions(FcmInvalidTokenException.class) + .build(); + + return RetryRegistry.of(Map.of("fcm", fcmRetryConfig)); + } + + @Bean + public CircuitBreakerRegistry circuitBreakerRegistry() { + CircuitBreakerConfig fcmCbConfig = CircuitBreakerConfig + .custom() + .failureRateThreshold(50) // 실패율 50% 이상 시 서킷 오픈 + .slowCallRateThreshold(100) + .slowCallDurationThreshold(Duration.ofSeconds(2)) // 2초 이상 걸리면 느린 호출로 간주 + .permittedNumberOfCallsInHalfOpenState(3) + .maxWaitDurationInHalfOpenState(Duration.ofSeconds(10)) + .slidingWindowSize(10) // 최근 10개의 호출을 통계로 사용 + .minimumNumberOfCalls(5) // 최소 5번은 호출된 후 통계 계산 + .waitDurationInOpenState(Duration.ofSeconds(30)) // 서킷 오픈 후 30초 대기 + // 서킷 브레이커가 전파받을 예외 설정 (Retry에서 걸러진 것들이 여기까지 옴) + .recordExceptions(FcmRetryableException.class, IOException.class) + .ignoreExceptions(FcmInvalidTokenException.class) + .build(); + + return CircuitBreakerRegistry.of(Map.of("fcm", fcmCbConfig)); + } +} diff --git a/src/main/java/book/book/notification/NotificationTestApi.java b/src/main/java/book/book/notification/api/NotificationTestApi.java similarity index 81% rename from src/main/java/book/book/notification/NotificationTestApi.java rename to src/main/java/book/book/notification/api/NotificationTestApi.java index 1f3b2028..33db0bf7 100644 --- a/src/main/java/book/book/notification/NotificationTestApi.java +++ b/src/main/java/book/book/notification/api/NotificationTestApi.java @@ -1,13 +1,12 @@ -package book.book.notification; +package book.book.notification.api; import book.book.common.response.ResponseForm; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.service.FCMService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -22,9 +21,9 @@ public class NotificationTestApi { @PostMapping("/notification/test") @Operation(summary = "알림 테스트용도 API") - public ResponseForm sendNotification(@RequestBody NotificationMessage message) { + public ResponseForm sendNotification(@RequestBody NotificationDto message) { log.info("알림 전달 메시지 : {}", message); fcmService.send(message); - return ResponseForm.ok(); + return ResponseForm.ok(); } } diff --git a/src/main/java/book/book/notification/domain/FCMSendStatus.java b/src/main/java/book/book/notification/domain/FCMSendStatus.java index 9fbb6533..d777d17c 100644 --- a/src/main/java/book/book/notification/domain/FCMSendStatus.java +++ b/src/main/java/book/book/notification/domain/FCMSendStatus.java @@ -7,9 +7,11 @@ */ @RequiredArgsConstructor public enum FCMSendStatus { - PENDING("전송 대기"), // FCM 전송 대기 중 - SENT("전송 성공"), // FCM 전송 성공 - FAILED("전송 실패"); // FCM 전송 실패 + PENDING("전송 대기"), // FCM 전송 대기 중 + SENT("전송 성공"), // FCM 전송 성공 + FAILED("전송 실패"), // FCM 전송 실패 (재시도 가능) + FAILED_NO_RETRY("전송 실패(재시도 불가)"), // FCM 전송 실패 (재시도 불가, 예: Invalid Token) + RETRY_LIMIT_EXCEEDED("재시도 횟수 초과"); // 재시도 횟수 초과로 인한 실패 private final String description; } diff --git a/src/main/java/book/book/notification/domain/Notification.java b/src/main/java/book/book/notification/domain/Notification.java index 0494aa77..20d90451 100644 --- a/src/main/java/book/book/notification/domain/Notification.java +++ b/src/main/java/book/book/notification/domain/Notification.java @@ -56,14 +56,6 @@ public class Notification extends BaseTimeEntity { @Column(nullable = false) private boolean isRead = false; - /** - * FCM 전송 상태 - */ - @Enumerated(EnumType.STRING) - @Builder.Default - @Column(nullable = false) - private FCMSendStatus fcmSendStatus = FCMSendStatus.PENDING; - /** * 관련 리소스 정보를 JSON 형식으로 저장 * 프론트엔드에서 라우팅에 필요한 모든 정보 포함 @@ -76,18 +68,8 @@ public class Notification extends BaseTimeEntity { @Column(columnDefinition = "TEXT") private String metadata; - /** - * 알림 읽음 처리 - */ public void markAsRead() { this.isRead = true; } - - public void markAsSent() { - this.fcmSendStatus = FCMSendStatus.SENT; - } - - public void markAsFailed() { - this.fcmSendStatus = FCMSendStatus.FAILED; - } } + diff --git a/src/main/java/book/book/notification/domain/NotificationDeviceLog.java b/src/main/java/book/book/notification/domain/NotificationDeviceLog.java new file mode 100644 index 00000000..71d2ce61 --- /dev/null +++ b/src/main/java/book/book/notification/domain/NotificationDeviceLog.java @@ -0,0 +1,103 @@ +package book.book.notification.domain; + +import book.book.common.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "notification_device_log") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeviceLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + /** + * 알림 발송 대상 FCM 토큰의 ID (토큰 삭제 시 추적을 위해 저장, FK 제약조건은 없을 수 있음) + */ + @Column(nullable = false, unique = true) + private Long fcmTokenId; + + /** + * 기기 타입 (예: IOS, ANDROID, WEB) + * 토큰이 삭제되어도 어떤 기기로 보냈는지 알 수 있도록 저장 + */ + @Column(nullable = false) + private String deviceType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private FCMSendStatus status; + + @Column(columnDefinition = "TEXT") + private String errorCode; + + @Column(nullable = false) + private int retryCount = 0; + + @Builder + public NotificationDeviceLog(Notification notification, Long fcmTokenId, String deviceType, FCMSendStatus status, + String errorCode) { + this.notification = notification; + this.fcmTokenId = fcmTokenId; + this.deviceType = deviceType; + this.status = status; + this.errorCode = errorCode; + } + + public void updateStatus(FCMSendStatus status, String errorCode) { + this.status = status; + this.errorCode = errorCode; + } + + public void incrementRetryCount() { + this.retryCount++; + } + + public void succeed() { + this.status = FCMSendStatus.SENT; + this.errorCode = null; + } + + public void fail(String errorCode) { + this.status = FCMSendStatus.FAILED; + this.errorCode = errorCode; + } + + public void failNoRetry(String errorCode) { + this.status = FCMSendStatus.FAILED_NO_RETRY; + this.errorCode = errorCode; + } + + public void failRetryLimitExceeded() { + this.status = FCMSendStatus.RETRY_LIMIT_EXCEEDED; + } + + public static NotificationDeviceLog of(Notification notification, FCMToken token) { + return NotificationDeviceLog.builder() + .notification(notification) + .fcmTokenId(token.getId()) + .deviceType(token.getDeviceType().name()) + .status(FCMSendStatus.PENDING) + .build(); + } +} diff --git a/src/main/java/book/book/notification/dto/NotificationDto.java b/src/main/java/book/book/notification/dto/NotificationDto.java new file mode 100644 index 00000000..4d3ec44a --- /dev/null +++ b/src/main/java/book/book/notification/dto/NotificationDto.java @@ -0,0 +1,46 @@ +package book.book.notification.dto; + +import book.book.notification.domain.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 가변적이지 않고 중간에 필드값이 추가됨 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder(toBuilder = true) +public class NotificationDto { + private Long notificationId; // DB에 저장된 알림 ID (FCM 전송용) + private Long receiverId; + private Long actorId; // 알림을 발생시킨 사용자 ID (시스템 알림의 경우 null) + private String title; + private String content; + private NotificationType notificationType; + + /** + * 알림 관련 리소스 정보 (프론트엔드 라우팅용) + * 예시: + * - 댓글: {"commentId": 456, "diaryId": 123} + * - 좋아요: {"diaryId": 123} + * - 퀴즈 완성: {"quizId": 789, "bookId": 321} + */ + private String metadata; // JSON String + + // 내부 로직용 필드 (DB 저장 및 전송 시 활용) + private Long notificationDeviceLogId; // 전송 로그 ID (상태 업데이트용) + private Long fcmTokenId; // FCM 토큰 ID (전송 대상 조회용) + private String fcmToken; // FCM 토큰 값 (실제 전송용) + + public NotificationDto withDetails(Long notificationId, Long deviceLogId, Long tokenId, String tokenValue) { + return this.toBuilder() + .notificationId(notificationId) + .notificationDeviceLogId(deviceLogId) + .fcmTokenId(tokenId) + .fcmToken(tokenValue) + .build(); + } +} diff --git a/src/main/java/book/book/notification/dto/NotificationMessage.java b/src/main/java/book/book/notification/dto/NotificationMessage.java deleted file mode 100644 index f1e93e4d..00000000 --- a/src/main/java/book/book/notification/dto/NotificationMessage.java +++ /dev/null @@ -1,30 +0,0 @@ -package book.book.notification.dto; - -import book.book.notification.domain.NotificationType; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class NotificationMessage { - private Long notificationId; // DB에 저장된 알림 ID (FCM 전송용) - private Long receiverId; - private Long actorId; // 알림을 발생시킨 사용자 ID (시스템 알림의 경우 null) - private String title; - private String content; - private NotificationType notificationType; - - /** - * 알림 관련 리소스 정보 (프론트엔드 라우팅용) - * 예시: - * - 댓글: {"commentId": 456, "diaryId": 123} - * - 좋아요: {"diaryId": 123} - * - 퀴즈 완성: {"quizId": 789, "bookId": 321} - */ - private Map metadata; -} diff --git a/src/main/java/book/book/notification/event/NotificationEventListener.java b/src/main/java/book/book/notification/event/NotificationEventListener.java index 318c7128..fedaf55e 100644 --- a/src/main/java/book/book/notification/event/NotificationEventListener.java +++ b/src/main/java/book/book/notification/event/NotificationEventListener.java @@ -9,7 +9,7 @@ import book.book.follow.event.dto.FollowEvent; import book.book.member.entity.Member; import book.book.member.repository.MemberRepository; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.service.NotificationFacade; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; @@ -37,7 +37,7 @@ public void handleLikeNotification(LikeEvent event) { Member liker = memberRepository.findByIdOrElseThrow(event.likerId()); if (!diary.isOwner(liker.getId())) { - NotificationMessage message = messageFactory.buildLikeNotification(diary.getMember(), liker, + NotificationDto message = messageFactory.buildLikeNotification(diary.getMember(), liker, event.diaryId()); notificationFacade.saveAndSend(message); } @@ -66,7 +66,7 @@ private void sendReplyNotification(CommentEvent event, Member commenter) { DiaryComment parentComment = diaryCommentRepository.findByIdOrElseThrow(event.parentCommentId()); if (!parentComment.isOwner(commenter.getId())) { - NotificationMessage message = messageFactory.buildReplyNotification(parentComment.getMember(), commenter, + NotificationDto message = messageFactory.buildReplyNotification(parentComment.getMember(), commenter, event.content(), event.parentCommentId(), event.diaryId()); notificationFacade.saveAndSend(message); } @@ -76,7 +76,7 @@ private void sendCommentNotification(CommentEvent event, Member commenter) { ReadingDiary diary = readingDiaryRepository.findByIdOrElseThrow(event.diaryId()); if (!diary.isOwner(commenter.getId())) { - NotificationMessage message = messageFactory.buildCommentNotification(diary.getMember(), commenter, + NotificationDto message = messageFactory.buildCommentNotification(diary.getMember(), commenter, event.content(), event.parentCommentId(), event.diaryId()); notificationFacade.saveAndSend(message); } @@ -87,7 +87,7 @@ private void sendCommentNotification(CommentEvent event, Member commenter) { public void handleFollowNotification(FollowEvent event) { Member follower = memberRepository.findByIdOrElseThrow(event.followerId()); - NotificationMessage message = messageFactory.buildFollowNotification(event.followeeId(), follower); + NotificationDto message = messageFactory.buildFollowNotification(event.followeeId(), follower); notificationFacade.saveAndSend(message); } } diff --git a/src/main/java/book/book/notification/event/NotificationMessageFactory.java b/src/main/java/book/book/notification/event/NotificationMessageFactory.java index d4692c62..3008ba86 100644 --- a/src/main/java/book/book/notification/event/NotificationMessageFactory.java +++ b/src/main/java/book/book/notification/event/NotificationMessageFactory.java @@ -1,88 +1,119 @@ package book.book.notification.event; import book.book.member.entity.Member; +import book.book.notification.domain.FCMToken; +import book.book.notification.domain.Notification; import book.book.notification.domain.NotificationType; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class NotificationMessageFactory { - public NotificationMessage buildLikeNotification(Member receiver, Member liker, Long diaryId) { + private final ObjectMapper objectMapper; + + public NotificationDto buildLikeNotification(Member receiver, Member liker, Long diaryId) { String content = liker.getNickName() + "님이 회원님의 독서일지에 좋아요를 눌렀습니다."; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiver.getId()) .actorId(liker.getId()) .content(content) .notificationType(NotificationType.LIKE) - .metadata(Map.of("diaryId", diaryId)) + .metadata(toJson(Map.of("diaryId", diaryId))) .build(); } - public NotificationMessage buildCommentNotification(Member receiver, Member commenter, String commentContent, - Long commentId, Long diaryId) { + public NotificationDto buildCommentNotification(Member receiver, Member commenter, String commentContent, + Long commentId, Long diaryId) { String content = commenter.getNickName() + "님이 회원님의 독서일지에 댓글을 남겼습니다: " + commentContent; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiver.getId()) .actorId(commenter.getId()) .content(content) .notificationType(NotificationType.COMMENT) - .metadata(Map.of( + .metadata(toJson(Map.of( "commentId", commentId, - "diaryId", diaryId - )) + "diaryId", diaryId))) .build(); } - public NotificationMessage buildReplyNotification(Member receiver, Member commenter, String replyContent, - Long parentCommentId, Long diaryId) { + public NotificationDto buildReplyNotification(Member receiver, Member commenter, String replyContent, + Long parentCommentId, Long diaryId) { String content = commenter.getNickName() + "님이 회원님의 댓글에 답글을 남겼습니다: " + replyContent; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiver.getId()) .actorId(commenter.getId()) .content(content) .notificationType(NotificationType.REPLY) - .metadata(Map.of( + .metadata(toJson(Map.of( "parentCommentId", parentCommentId, - "diaryId", diaryId - )) + "diaryId", diaryId))) .build(); } - public NotificationMessage buildFollowNotification(Long receiverId, Member follower) { + public NotificationDto buildFollowNotification(Long receiverId, Member follower) { String content = follower.getNickName() + "님이 회원님을 팔로우하기 시작했습니다."; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiverId) .actorId(follower.getId()) .content(content) .notificationType(NotificationType.FOLLOW) - .metadata(Map.of("memberId", follower.getId())) + .metadata(toJson(Map.of("memberId", follower.getId()))) .build(); } - public NotificationMessage buildChatNotification(Long receiverId, Member sender, String messageContent, - Long chatRoomId) { + public NotificationDto buildChatNotification(Long receiverId, Member sender, String messageContent, + Long chatRoomId) { String content = sender.getNickName() + ": " + messageContent; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiverId) .actorId(sender.getId()) .content(content) .notificationType(NotificationType.CHAT) - .metadata(Map.of("chatRoomId", chatRoomId)) + .metadata(toJson(Map.of("chatRoomId", chatRoomId))) .build(); } - public NotificationMessage buildQuizCompletedNotification(Long receiverId, String bookTitle, Long bookId, Long challengeId) { + public NotificationDto buildQuizCompletedNotification(Long receiverId, String bookTitle, Long bookId, + Long challengeId) { String content = "'" + bookTitle + "' 책의 퀴즈가 생성되었습니다."; - return NotificationMessage.builder() + return NotificationDto.builder() .receiverId(receiverId) .content(content) .notificationType(NotificationType.QUIZ_COMPLETED) - .metadata(Map.of( + .metadata(toJson(Map.of( "bookId", bookId, - "challengeId", challengeId - )) + "challengeId", challengeId))) + .build(); + } + + /** + * Facade 재시도 로직용 (Notification 엔티티 -> NotificationMessage 변환) + */ + public NotificationDto reconstructForRetry(Notification notification, FCMToken fcmToken, Long deviceLogId) { + return NotificationDto.builder() + .notificationId(notification.getId()) + .receiverId(notification.getReceiver().getId()) + .actorId(notification.getActor() != null ? notification.getActor().getId() : null) + .title(notification.getNotificationType().name()) + .content(notification.getMessage()) + .notificationType(notification.getNotificationType()) + .metadata(notification.getMetadata()) // 이미 JSON String + .notificationDeviceLogId(deviceLogId) + .fcmTokenId(fcmToken.getId()) + .fcmToken(fcmToken.getToken()) .build(); } + + private String toJson(Map data) { + try { + return objectMapper.writeValueAsString(data); + } catch (Exception e) { + throw new RuntimeException("Metadata JSON conversion failed", e); + } + } } diff --git a/src/main/java/book/book/notification/exception/FcmInvalidTokenException.java b/src/main/java/book/book/notification/exception/FcmInvalidTokenException.java new file mode 100644 index 00000000..2cef598d --- /dev/null +++ b/src/main/java/book/book/notification/exception/FcmInvalidTokenException.java @@ -0,0 +1,25 @@ +package book.book.notification.exception; + +public class FcmInvalidTokenException extends RuntimeException { + + private final String token; + + public FcmInvalidTokenException(String token, String message) { + super(message); + this.token = token; + } + + public FcmInvalidTokenException(String token, String message, Throwable cause) { + super(message, cause); + this.token = token; + } + + public FcmInvalidTokenException(String message, Throwable cause) { + super(message, cause); + this.token = null; + } + + public String getToken() { + return token; + } +} diff --git a/src/main/java/book/book/notification/exception/FcmRetryableException.java b/src/main/java/book/book/notification/exception/FcmRetryableException.java new file mode 100644 index 00000000..9911a650 --- /dev/null +++ b/src/main/java/book/book/notification/exception/FcmRetryableException.java @@ -0,0 +1,11 @@ +package book.book.notification.exception; + +public class FcmRetryableException extends RuntimeException { + public FcmRetryableException(String message) { + super(message); + } + + public FcmRetryableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/book/book/notification/external/FirebaseClient.java b/src/main/java/book/book/notification/external/FirebaseClient.java index aaca706b..8bcd8acb 100644 --- a/src/main/java/book/book/notification/external/FirebaseClient.java +++ b/src/main/java/book/book/notification/external/FirebaseClient.java @@ -3,9 +3,14 @@ import book.book.common.CustomException; import book.book.common.ErrorCode; +import book.book.notification.exception.FcmInvalidTokenException; +import book.book.notification.exception.FcmRetryableException; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,11 +23,51 @@ public class FirebaseClient { private final FirebaseMessaging firebaseMessaging; + /** + * Resilience4jConfig에서 정의한 Retry 및 CircuitBreaker 적용 + */ + @Retry(name = "fcm") + @CircuitBreaker(name = "fcm") public String send(Message message) { try { return firebaseMessaging.send(message); } catch (FirebaseMessagingException e) { - log.error("FCM 메시지 전송 실패: {}", e.getMessage(), e); + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + if (errorCode != null) { + switch (errorCode) { + case INVALID_ARGUMENT: // 400 + case UNREGISTERED: // 404 + log.warn("FCM 전송 실패 (Invalid Token): {}", e.getMessage()); + throw new FcmInvalidTokenException("잘못된 토큰입니다: " + e.getMessage(), e); + case SENDER_ID_MISMATCH: // 403 + log.warn("FCM 전송 실패 (Sender ID Mismatch): {}", e.getMessage()); + throw new FcmInvalidTokenException("Sender ID 불일치: " + e.getMessage(), e); + case THIRD_PARTY_AUTH_ERROR: // 401 + log.warn("FCM 전송 실패 (Third Party Auth Error): {}", e.getMessage()); + throw new FcmInvalidTokenException("인증 오류: " + e.getMessage(), e); + case QUOTA_EXCEEDED: // 429 + case UNAVAILABLE: // 503 + case INTERNAL: // 500 + log.warn("FCM 서버 오류 (Retryable): {}", e.getMessage()); + throw new FcmRetryableException(e.getMessage(), e); + } + } + + // ErrorCode가 없는 경우 HTTP Status 확인 + if (e.getHttpResponse() != null) { + int status = e.getHttpResponse().getStatusCode(); + if (status == 429 || status >= 500) { + log.warn("FCM 서버 오류 (HTTP {}): {}", status, e.getMessage()); + throw new FcmRetryableException(e.getMessage(), e); + } + if (status == 400 || status == 404 || status == 403 || status == 401) { + log.warn("FCM 클라이언트 오류 (HTTP {}): {}", status, e.getMessage()); + throw new FcmInvalidTokenException(e.getMessage(), e); + } + } + + log.error("FCM 알 수 없는 오류: {}", e.getMessage(), e); throw new CustomException(ErrorCode.FCM_SEND_ERROR, e); } } diff --git a/src/main/java/book/book/notification/repository/FCMTokenRepository.java b/src/main/java/book/book/notification/repository/FCMTokenRepository.java index 0a5fdb8c..19614bd0 100644 --- a/src/main/java/book/book/notification/repository/FCMTokenRepository.java +++ b/src/main/java/book/book/notification/repository/FCMTokenRepository.java @@ -1,6 +1,7 @@ package book.book.notification.repository; -import book.book.notification.domain.DeviceType; +import book.book.common.CustomException; +import book.book.common.ErrorCode; import book.book.notification.domain.FCMToken; import java.util.List; import java.util.Optional; @@ -9,7 +10,11 @@ public interface FCMTokenRepository extends JpaRepository { List findAllByUserId(Long userId); - List findAllByUserIdAndDeviceType(Long userId, DeviceType deviceType); - Optional findByToken(String token); + + default FCMToken findByIdOrElseThrow(Long id) { + return findById(id).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_FCMTOKEN)); + } + + Optional findById(Long id); } diff --git a/src/main/java/book/book/notification/repository/NotificationDeviceLogRepository.java b/src/main/java/book/book/notification/repository/NotificationDeviceLogRepository.java new file mode 100644 index 00000000..3b77880d --- /dev/null +++ b/src/main/java/book/book/notification/repository/NotificationDeviceLogRepository.java @@ -0,0 +1,34 @@ +package book.book.notification.repository; + +import book.book.common.CustomException; +import book.book.common.ErrorCode; +import book.book.notification.domain.FCMSendStatus; +import book.book.notification.domain.NotificationDeviceLog; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface NotificationDeviceLogRepository extends JpaRepository { + + default NotificationDeviceLog findByIdOrElseThrow(Long id) { + return findById(id).orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_LOG_NOT_FOUND)); + + } + + Optional findByFcmTokenId(Long fcmTokenId); + + @Query("SELECT l FROM NotificationDeviceLog l " + + "JOIN FETCH l.notification n " + + "JOIN FETCH n.receiver " + + "LEFT JOIN FETCH n.actor " + + "WHERE l.status IN :statuses " + + "AND l.createdDate >= :cutoffTime " + + "AND l.createdDate <= :pendingThreshold") + List findRetryCandidates( + LocalDateTime cutoffTime, + LocalDateTime pendingThreshold, + List statuses); + +} diff --git a/src/main/java/book/book/notification/repository/NotificationRepository.java b/src/main/java/book/book/notification/repository/NotificationRepository.java index 49b7e9ea..3be2634b 100644 --- a/src/main/java/book/book/notification/repository/NotificationRepository.java +++ b/src/main/java/book/book/notification/repository/NotificationRepository.java @@ -2,13 +2,8 @@ import book.book.common.CustomException; import book.book.common.ErrorCode; -import book.book.notification.domain.FCMSendStatus; import book.book.notification.domain.Notification; -import java.time.LocalDateTime; -import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; public interface NotificationRepository extends JpaRepository { @@ -16,22 +11,6 @@ public interface NotificationRepository extends JpaRepository :cutoffTime " + - "AND (n.fcmSendStatus = 'FAILED' " + - "OR (n.fcmSendStatus = 'PENDING' AND n.createdDate < :pendingThreshold))") - List findRetryCandidates(LocalDateTime cutoffTime, LocalDateTime pendingThreshold); - - @Modifying - @org.springframework.transaction.annotation.Transactional - @Query("DELETE FROM Notification n " + - "WHERE n.createdDate <= :cutoffTime " + - "AND n.fcmSendStatus IN :statuses") - void deleteOldNotifications(LocalDateTime cutoffTime, List statuses); - default Notification findByIdOrElseThrow(Long notificationId) { return findById(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); diff --git a/src/main/java/book/book/notification/service/NotificationRecoveryScheduler.java b/src/main/java/book/book/notification/scheduler/NotificationRecoveryScheduler.java similarity index 92% rename from src/main/java/book/book/notification/service/NotificationRecoveryScheduler.java rename to src/main/java/book/book/notification/scheduler/NotificationRecoveryScheduler.java index 45b65899..aea9bdac 100644 --- a/src/main/java/book/book/notification/service/NotificationRecoveryScheduler.java +++ b/src/main/java/book/book/notification/scheduler/NotificationRecoveryScheduler.java @@ -1,5 +1,6 @@ -package book.book.notification.service; +package book.book.notification.scheduler; +import book.book.notification.service.NotificationFacade; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/book/book/notification/service/FCMService.java b/src/main/java/book/book/notification/service/FCMService.java index 23eac6be..01169d80 100644 --- a/src/main/java/book/book/notification/service/FCMService.java +++ b/src/main/java/book/book/notification/service/FCMService.java @@ -1,56 +1,41 @@ package book.book.notification.service; -import book.book.common.CustomException; -import book.book.common.ErrorCode; -import book.book.notification.external.FirebaseClient; import book.book.notification.domain.FCMToken; import book.book.notification.dto.FCMTokenCreateRequest; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; +import book.book.notification.external.FirebaseClient; import book.book.notification.repository.FCMTokenRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; import java.util.HashMap; -import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - @Service @Slf4j @RequiredArgsConstructor -public class FCMService implements NotificationSender { +public class FCMService { private static final String BROADCAST_TOPIC = "all"; private final FCMTokenRepository fcmTokenRepository; - private final ObjectMapper objectMapper; private final FirebaseClient firebaseClient; + private final NotificationDeviceLogService notificationDeviceLogService; - @Override - public void send(NotificationMessage message) { - List fcmTokens = fcmTokenRepository.findAllByUserId(message.getReceiverId()); - - if (fcmTokens.isEmpty()) { - throw new CustomException(ErrorCode.NOT_FOUND_FCMTOKEN); - } - - // 사용자의 모든 디바이스로 알림 전송 - fcmTokens.forEach(fcmToken -> sendPushNotification(fcmToken.getToken(), message)); - } - - private void sendPushNotification(String fcmToken, NotificationMessage notificationMessage) { - Map data = buildDataPayload(notificationMessage); + /** + * 단일 디바이스 알림 전송 + * 외부 API 호출이므로 트랜잭션 분리 + */ + public void send(NotificationDto notificationDto) { + Map data = buildDataPayload(notificationDto); Message message = Message.builder() - .setToken(fcmToken) - .setNotification(Notification.builder() - .setTitle(notificationMessage.getTitle()) - .setBody(notificationMessage.getContent()) + .setToken(notificationDto.getFcmToken()) + .setNotification(com.google.firebase.messaging.Notification.builder() + .setTitle(notificationDto.getTitle()) + .setBody(notificationDto.getContent()) .build()) .putAllData(data) .build(); @@ -58,43 +43,13 @@ private void sendPushNotification(String fcmToken, NotificationMessage notificat firebaseClient.send(message); } - /** - * FCM data payload 구성 - * 클라이언트가 알림 탭 시 필요한 데이터 전달 - */ - private Map buildDataPayload(NotificationMessage message) { - Map data = new HashMap<>(); - - // 알림 ID (읽음 처리용) - if (message.getNotificationId() != null) { - data.put("notificationId", message.getNotificationId().toString()); - } - - // 알림 타입 - if (message.getNotificationType() != null) { - data.put("notificationType", message.getNotificationType().name()); - } - - // 메타데이터 (라우팅 정보) - if (message.getMetadata() != null && !message.getMetadata().isEmpty()) { - try { - String metadataJson = objectMapper.writeValueAsString(message.getMetadata()); - data.put("metadata", metadataJson); - } catch (JsonProcessingException e) { - throw new CustomException(ErrorCode.NOTIFICATION_METADATA_CONVERSION_ERROR, e); - } - } - - return data; - } - /** * Topic 기반 브로드캐스트 알림 전송 */ public void sendToTopic(String topic, String title, String content) { Message message = Message.builder() .setTopic(topic) - .setNotification(Notification.builder() + .setNotification(com.google.firebase.messaging.Notification.builder() .setTitle(title) .setBody(content) .build()) @@ -103,7 +58,9 @@ public void sendToTopic(String topic, String title, String content) { firebaseClient.send(message); } - @Transactional + /** + * FCM 토큰 저장 + */ public void save(FCMTokenCreateRequest request) { // FCM 토큰은 디바이스별로 고유하므로, 토큰으로 검색 // - 이미 존재하는 토큰 → 그대로 유지 (재로그인 또는 중복 요청) @@ -114,5 +71,48 @@ public void save(FCMTokenCreateRequest request) { } // 이미 존재하는 토큰이면 아무것도 하지 않음 (토큰, userId, deviceType 모두 동일할 것) } -} + /** + * 재시도 없는 실패로 처리하고 + * 다음 알림 때 사용하지 못하게 FCM 토큰 삭제 + */ + @Transactional + public void markAsFailedNoRetry(Long fcmTokenId, String errorCode) { + fcmTokenRepository.deleteById(fcmTokenId); + notificationDeviceLogService.markAsFailedNoRetry(fcmTokenId, errorCode); + } + + /** + * notificationDeviceLog 상태값 변경해야 하므로 FCM 토큰 삭제는 fcmService에서 처리 + */ + @Transactional + public void deleteToken(Long fcmTokenId) { + markAsFailedNoRetry(fcmTokenId, "Delete FCM token"); + } + + /** + * FCM data payload 구성 + * 클라이언트가 알림 탭 시 필요한 데이터 전달 + */ + private Map buildDataPayload(NotificationDto message) { + Map data = new HashMap<>(); + + // 알림 ID (읽음 처리용) + if (message.getNotificationId() != null) { + data.put("notificationId", message.getNotificationId().toString()); + } + + // 알림 타입 + if (message.getNotificationType() != null) { + data.put("notificationType", message.getNotificationType().name()); + } + + // 메타데이터 (라우팅 정보) - JSON String 그대로 전송 + if (message.getMetadata() != null && !message.getMetadata().isEmpty()) { + data.put("metadata", message.getMetadata()); + } + + return data; + } + +} diff --git a/src/main/java/book/book/notification/service/NotificationDeviceLogService.java b/src/main/java/book/book/notification/service/NotificationDeviceLogService.java new file mode 100644 index 00000000..6bbd4f34 --- /dev/null +++ b/src/main/java/book/book/notification/service/NotificationDeviceLogService.java @@ -0,0 +1,33 @@ +package book.book.notification.service; + +import book.book.notification.domain.NotificationDeviceLog; +import book.book.notification.repository.NotificationDeviceLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationDeviceLogService { + + private final NotificationDeviceLogRepository notificationDeviceLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markAsSuccess(Long logId) { + NotificationDeviceLog deviceLog = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + deviceLog.succeed(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markAsFailed(Long logId, String errorMessage) { + NotificationDeviceLog deviceLog = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + deviceLog.fail(errorMessage); + } + + @Transactional + public void markAsFailedNoRetry(Long fcmTokenId, String errorCode) { + notificationDeviceLogRepository.findByFcmTokenId(fcmTokenId) + .ifPresent(log -> log.failNoRetry(errorCode)); + } +} diff --git a/src/main/java/book/book/notification/service/NotificationFacade.java b/src/main/java/book/book/notification/service/NotificationFacade.java index 6cc92bf3..82bc5f30 100644 --- a/src/main/java/book/book/notification/service/NotificationFacade.java +++ b/src/main/java/book/book/notification/service/NotificationFacade.java @@ -1,14 +1,12 @@ package book.book.notification.service; -import book.book.notification.domain.Notification; -import book.book.notification.dto.NotificationMessage; -import book.book.notification.repository.NotificationRepository; +import book.book.notification.dto.NotificationDto; +import book.book.notification.exception.FcmInvalidTokenException; import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; /** * 알림 Facade @@ -20,76 +18,56 @@ public class NotificationFacade { private final NotificationService notificationService; - private final NotificationSender notificationSender; - private final NotificationRepository notificationRepository; + private final FCMService fcmService; + private final NotificationDeviceLogService deviceLogService; private static final int RETRY_WINDOW_HOURS = 6; + /** + * 알림 저장 및 FCM 전송 + */ + public void saveAndSend(NotificationDto message) { + List messages = notificationService.saveNotificationAndNotificationDeviceLogs(message); + + if (messages.isEmpty()) { + log.info("[알림] No tokens found or logs created for user: {}", message.getReceiverId()); + return; + } + + sendMessages(messages); + } + + /** + * 실패한 FCM 전송 재시도 + */ public void recoverFailedNotifications(LocalDateTime currentTime) { log.info("FCM 전송 재시도 시작: {}", currentTime); - cleanupOldNotifications(currentTime); - LocalDateTime cutoffTime = currentTime.minusHours(RETRY_WINDOW_HOURS); LocalDateTime pendingThreshold = currentTime.minusMinutes(10); - List pendingNotifications = notificationRepository.findRetryCandidates(cutoffTime, - pendingThreshold); - log.info("재전송 대상 알림 개수: {} ({}시간 이내 생성)", pendingNotifications.size(), RETRY_WINDOW_HOURS); + List retryMessages = notificationService.findRetryMessages(cutoffTime, pendingThreshold); - pendingNotifications.forEach(this::retrySingleNotification); + log.info("재전송 대상 디바이스 로그 개수: {} ({}시간 이내 생성)", retryMessages.size(), RETRY_WINDOW_HOURS); - log.info("FCM 전송 재시도 완료: {}", currentTime); - } + sendMessages(retryMessages); - private void cleanupOldNotifications(LocalDateTime currentTime) { - LocalDateTime cleanupCutoff = currentTime.minusHours(RETRY_WINDOW_HOURS); - notificationRepository.deleteOldNotifications( - cleanupCutoff, - List.of(book.book.notification.domain.FCMSendStatus.FAILED, - book.book.notification.domain.FCMSendStatus.PENDING)); - log.info("시의성 지난 알림 삭제 완료 (기준: {})", cleanupCutoff); + log.info("FCM 전송 재시도 완료: {}", currentTime); } - private void retrySingleNotification(Notification notification) { - try { - NotificationMessage message = NotificationMessage.builder() - .notificationId(notification.getId()) - .receiverId(notification.getReceiver().getId()) - .actorId(notification.getActor() != null ? notification.getActor().getId() : null) - .title(notification.getNotificationType().name()) - .content(notification.getMessage()) - .notificationType(notification.getNotificationType()) - .build(); - - notificationSender.send(message); - notificationService.updateStatusToSent(notification.getId()); - log.info("알림 ID {} FCM 재전송 성공", notification.getId()); - } catch (Exception e) { - log.error("알림 ID {} FCM 재전송 실패: {}", notification.getId(), e.getMessage()); - notificationService.updateStatusToFailed(notification.getId()); + private void sendMessages(List messages) { + for (NotificationDto msg : messages) { + try { + fcmService.send(msg); + deviceLogService.markAsSuccess(msg.getNotificationDeviceLogId()); + } catch (FcmInvalidTokenException e) { + log.warn("유효하지 않은 FCM 토큰 감지 - 삭제 처리: {}", msg.getFcmToken()); + fcmService.markAsFailedNoRetry(msg.getFcmTokenId(), e.getMessage()); + } catch (Exception e) { + log.error("FCM 전송 실패 logId={}: {}", msg.getNotificationDeviceLogId(), e.getMessage()); + deviceLogService.markAsFailed(msg.getNotificationDeviceLogId(), e.getMessage()); + } } } - /** - * 알림을 DB에 저장하고 FCM으로 전송 - * 트랜잭션 분리 전략: - * 1. DB 저장을 먼저 커밋 (별도 트랜잭션) - * 2. FCM 전송 시도 - * 3. 성공/실패에 따라 상태 업데이트 (별도 트랜잭션) - */ - public void saveAndSend(NotificationMessage message) { - // 1. DB 저장 (별도 트랜잭션으로 즉시 커밋) - Notification savedNotification = notificationService.saveNotification(message); - message.setNotificationId(savedNotification.getId()); - - // 2. FCM 전송 및 상태 업데이트 - try { - notificationSender.send(message); - notificationService.updateStatusToSent(savedNotification.getId()); - } catch (RuntimeException e) { - log.warn("FCM 전송 실패 - notificationId: {}, error: {}", savedNotification.getId(), e.getMessage()); - notificationService.updateStatusToFailed(savedNotification.getId()); - } - } } diff --git a/src/main/java/book/book/notification/service/NotificationSender.java b/src/main/java/book/book/notification/service/NotificationSender.java deleted file mode 100644 index 4cbe24c1..00000000 --- a/src/main/java/book/book/notification/service/NotificationSender.java +++ /dev/null @@ -1,9 +0,0 @@ -package book.book.notification.service; - - -import book.book.notification.dto.NotificationMessage; - -public interface NotificationSender { - - void send(NotificationMessage message); -} diff --git a/src/main/java/book/book/notification/service/NotificationService.java b/src/main/java/book/book/notification/service/NotificationService.java index 4b8a8206..ee28cdbe 100644 --- a/src/main/java/book/book/notification/service/NotificationService.java +++ b/src/main/java/book/book/notification/service/NotificationService.java @@ -1,18 +1,28 @@ package book.book.notification.service; -import book.book.common.CustomException; import book.book.common.ErrorCode; +import book.book.common.error_notification.AsyncErrorNotificationDto; +import book.book.common.error_notification.ErrorNotificationService; import book.book.member.entity.Member; import book.book.member.repository.MemberRepository; +import book.book.notification.domain.FCMSendStatus; +import book.book.notification.domain.FCMToken; import book.book.notification.domain.Notification; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.domain.NotificationDeviceLog; +import book.book.notification.dto.NotificationDto; +import book.book.notification.event.NotificationMessageFactory; +import book.book.notification.repository.FCMTokenRepository; +import book.book.notification.repository.NotificationDeviceLogRepository; import book.book.notification.repository.NotificationRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -22,72 +32,128 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final MemberRepository memberRepository; - private final ObjectMapper objectMapper; + private final FCMTokenRepository fcmTokenRepository; + private final NotificationDeviceLogRepository notificationDeviceLogRepository; + private final ErrorNotificationService errorNotificationService; + private final NotificationMessageFactory messageFactory; + + private static final int MAX_RETRY_COUNT = 3; + + /** + * 알림을 DB에 저장하고 DeviceLog를 생성하여 반환 (별도 트랜잭션) + */ + @Transactional + public List saveNotificationAndNotificationDeviceLogs(NotificationDto dto) { + Notification savedNotification = saveNotification(dto); + dto.setNotificationId(savedNotification.getId()); + + List fcmTokens = fcmTokenRepository.findAllByUserId(dto.getReceiverId()); + if (fcmTokens.isEmpty()) { + return Collections.emptyList(); + } + + List savedLogs = saveDeviceLogs(savedNotification, fcmTokens); + + return convertToNotificationDtos(dto, fcmTokens, savedLogs); + } + + private List saveDeviceLogs(Notification notification, List fcmTokens) { + List logs = fcmTokens.stream() + .map(fcmToken -> NotificationDeviceLog.of(notification, fcmToken)) + .toList(); + return notificationDeviceLogRepository.saveAll(logs); + } + + private List convertToNotificationDtos(NotificationDto originalDto, + List tokens, + List logs) { + Map tokenValueMap = tokens.stream() + .collect(Collectors.toMap(FCMToken::getId, FCMToken::getToken)); + + return logs.stream() + .map(log -> originalDto.withDetails( + log.getNotification().getId(), + log.getId(), + log.getFcmTokenId(), + tokenValueMap.get(log.getFcmTokenId()))) + .toList(); + } /** - * 알림을 DB에 저장 (별도 트랜잭션) + * 알림을 DB에 저장 (내부 호출용) */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public Notification saveNotification(NotificationMessage message) { + private Notification saveNotification(NotificationDto message) { Member receiver = memberRepository.findByIdOrElseThrow(message.getReceiverId()); Member actor = message.getActorId() != null ? memberRepository.findByIdOrElseThrow(message.getActorId()) : null; - String metadataJson = convertMetadataToJson(message.getMetadata()); - Notification notification = Notification.builder() .receiver(receiver) .actor(actor) .notificationType(message.getNotificationType()) .message(message.getContent()) - .metadata(metadataJson) + .metadata(message.getMetadata()) .build(); return notificationRepository.save(notification); } - /** - * 알림 상태를 전송 성공으로 업데이트 (별도 트랜잭션) + * 알림 읽음 처리 */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void updateStatusToSent(Long notificationId) { + @Transactional + public void markAsRead(Long notificationId) { Notification notification = notificationRepository.findByIdOrElseThrow(notificationId); - notification.markAsSent(); + notification.markAsRead(); } /** - * 알림 상태를 전송 실패로 업데이트 (별도 트랜잭션) + * 재전송 대상 메시지 조회 및 처리 (재시도 횟수 증가, 토큰 확인) */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void updateStatusToFailed(Long notificationId) { - Notification notification = notificationRepository.findByIdOrElseThrow(notificationId); - notification.markAsFailed(); - } + @Transactional + public List findRetryMessages(LocalDateTime cutoffTime, LocalDateTime pendingThreshold) { + List pendingLogs = notificationDeviceLogRepository.findRetryCandidates( + cutoffTime, + pendingThreshold, + List.of(FCMSendStatus.FAILED, FCMSendStatus.PENDING)); + List messages = new ArrayList<>(); - /** - * Map을 JSON 문자열로 변환 - */ - private String convertMetadataToJson(java.util.Map metadata) { - if (metadata == null || metadata.isEmpty()) { - return null; - } - try { - return objectMapper.writeValueAsString(metadata); - } catch (JsonProcessingException e) { - log.error("메타데이터 JSON 변환 실패: {}", metadata, e); - throw new CustomException(ErrorCode.NOTIFICATION_METADATA_CONVERSION_ERROR, e); + for (NotificationDeviceLog deviceLog : pendingLogs) { + if (deviceLog.getRetryCount() >= MAX_RETRY_COUNT) { + deviceLog.failRetryLimitExceeded(); + sendAdminAlert(deviceLog); + continue; + } + + deviceLog.incrementRetryCount(); + + FCMToken fcmToken = fcmTokenRepository.findByIdOrElseThrow(deviceLog.getFcmTokenId()); + + messages.add(messageFactory.reconstructForRetry(deviceLog.getNotification(), fcmToken, deviceLog.getId())); } + return messages; } - /** - * 알림 읽음 처리 - */ - @Transactional - public void markAsRead(Long notificationId) { - Notification notification = notificationRepository.findByIdOrElseThrow(notificationId); - notification.markAsRead(); + private void sendAdminAlert(NotificationDeviceLog deviceLog) { + try { + Notification notification = deviceLog.getNotification(); + AsyncErrorNotificationDto notificationDto = AsyncErrorNotificationDto + .builder() + .errorCode(ErrorCode.FCM_SEND_ERROR) + .contextInfo("FCM Device Log Retry Limit Exceeded") + .additionalInfo(Map.of( + "logId", String.valueOf(deviceLog.getId()), + "notificationId", String.valueOf(notification.getId()), + "receiverId", String.valueOf(notification.getReceiver().getId()), + "retryCount", String.valueOf(deviceLog.getRetryCount()), + "errorDetail", + deviceLog.getErrorCode() != null ? deviceLog.getErrorCode() : "Unknown Error")) + .build(); + errorNotificationService.sendErrorNotification(notificationDto); + } catch (Exception e) { + log.error("관리자 알림 발송 실패", e); + } } } diff --git a/src/main/java/book/book/quiz/event/QuizEventListener.java b/src/main/java/book/book/quiz/event/QuizEventListener.java index 8f6abfae..a9dabe86 100644 --- a/src/main/java/book/book/quiz/event/QuizEventListener.java +++ b/src/main/java/book/book/quiz/event/QuizEventListener.java @@ -6,7 +6,7 @@ import book.book.challenge.repository.ReadingChallengeRepository; import book.book.member.entity.Member; import book.book.member.repository.MemberRepository; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.event.NotificationMessageFactory; import book.book.notification.service.NotificationFacade; import java.util.Optional; @@ -44,7 +44,7 @@ public void handleQuizCreatedNotification(QuizCreatedEvent event) { Long challengeId = challengeOpt.get().getId(); - NotificationMessage message = messageFactory.buildQuizCompletedNotification( + NotificationDto message = messageFactory.buildQuizCompletedNotification( event.getMemberId(), book.getTitle(), event.getBookId(), diff --git a/src/test/java/book/book/config/TestExternalApiConfig.java b/src/test/java/book/book/config/TestExternalApiConfig.java index e19a02cf..00dda3a0 100644 --- a/src/test/java/book/book/config/TestExternalApiConfig.java +++ b/src/test/java/book/book/config/TestExternalApiConfig.java @@ -4,7 +4,6 @@ import book.book.common.error_notification.ErrorNotificationService; import book.book.crawler.service.AladinCrawlerService; -import book.book.notification.external.FirebaseClient; import book.book.quiz.external.gemini.GeminiSdkClient; import book.book.search.service.AladinService; import org.springframework.boot.test.context.TestConfiguration; @@ -62,14 +61,14 @@ public AladinCrawlerService aladinCrawlerService() { } /** - * Firebase Cloud Messaging 클라이언트를 Mock으로 대체 - * - FCM 푸시 알림 전송에 사용 - * - 실제 Firebase API 호출 방지 + * Firebase Cloud Messaging을 Mock으로 대체 + * - 실제 Firebase API 대신 Mock 객체 사용 + * - FirebaseClient의 재시도 로직 등을 테스트할 수 있도록 함 */ @Bean @Primary - public FirebaseClient firebaseClient() { - return mock(FirebaseClient.class); + public com.google.firebase.messaging.FirebaseMessaging firebaseMessaging() { + return mock(com.google.firebase.messaging.FirebaseMessaging.class); } /** diff --git a/src/test/java/book/book/notification/event/NotificationEventListenerTest.java b/src/test/java/book/book/notification/event/NotificationEventListenerTest.java index df2b5d9d..2d7b24c9 100644 --- a/src/test/java/book/book/notification/event/NotificationEventListenerTest.java +++ b/src/test/java/book/book/notification/event/NotificationEventListenerTest.java @@ -15,7 +15,7 @@ import book.book.member.entity.Member; import book.book.member.fixture.MemberFixture; import book.book.member.repository.MemberRepository; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.service.NotificationFacade; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -79,7 +79,7 @@ void setUp() { void 다른_사용자가_다이어리에_좋아요_시_알림을_발송한다() { // given LikeEvent event = new LikeEvent(diary.getId(), diaryOwner.getId(), 1L, otherMember.getId(), 1); - NotificationMessage expectedMessage = NotificationMessage.builder() + NotificationDto expectedMessage = NotificationDto.builder() .receiverId(diaryOwner.getId()) .build(); diff --git a/src/test/java/book/book/notification/fixture/NotificationMessageFixture.java b/src/test/java/book/book/notification/fixture/NotificationDtoFixture.java similarity index 61% rename from src/test/java/book/book/notification/fixture/NotificationMessageFixture.java rename to src/test/java/book/book/notification/fixture/NotificationDtoFixture.java index ee3dbef0..fbc4ad52 100644 --- a/src/test/java/book/book/notification/fixture/NotificationMessageFixture.java +++ b/src/test/java/book/book/notification/fixture/NotificationDtoFixture.java @@ -1,18 +1,16 @@ package book.book.notification.fixture; import book.book.notification.domain.NotificationType; -import book.book.notification.dto.NotificationMessage; -import book.book.notification.dto.NotificationMessage.NotificationMessageBuilder; -import java.util.Map; +import book.book.notification.dto.NotificationDto; import java.util.concurrent.ThreadLocalRandom; -public class NotificationMessageFixture { +public class NotificationDtoFixture { /** - * 기본 NotificationMessage + * 기본 NotificationMessageøπ * receiverId와 actorId는 테스트에서 명시적으로 주입해야 함 */ - public static NotificationMessage create() { + public static NotificationDto create() { long receiverId = ThreadLocalRandom.current().nextLong(1, 100000); long actorId = ThreadLocalRandom.current().nextLong(1, 100000); @@ -22,11 +20,11 @@ public static NotificationMessage create() { .build(); } - public static NotificationMessageBuilder builder() { - return NotificationMessage.builder() + public static NotificationDto.NotificationDtoBuilder builder() { + return NotificationDto.builder() .title("테스트 알림") .content("테스트 알림 내용입니다.") .notificationType(NotificationType.LIKE) - .metadata(Map.of("diaryId", 123L)); + .metadata("{\"diaryId\": 123}"); } } diff --git a/src/test/java/book/book/notification/scheduler/NotificationRecoverySchedulerTest.java b/src/test/java/book/book/notification/scheduler/NotificationRecoverySchedulerTest.java new file mode 100644 index 00000000..25b68d82 --- /dev/null +++ b/src/test/java/book/book/notification/scheduler/NotificationRecoverySchedulerTest.java @@ -0,0 +1,180 @@ +package book.book.notification.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import book.book.common.error_notification.AsyncErrorNotificationDto; +import book.book.common.error_notification.ErrorNotificationService; +import book.book.config.IntegrationTest; +import book.book.member.entity.Member; +import book.book.member.fixture.MemberFixture; +import book.book.member.repository.MemberRepository; +import book.book.notification.domain.FCMSendStatus; +import book.book.notification.domain.NotificationDeviceLog; +import book.book.notification.dto.NotificationDto; +import book.book.notification.fixture.FCMTokenFixture; +import book.book.notification.fixture.NotificationDtoFixture; +import book.book.notification.repository.FCMTokenRepository; +import book.book.notification.repository.NotificationDeviceLogRepository; +import book.book.notification.service.NotificationService; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +class NotificationRecoverySchedulerTest { + + @Autowired + private NotificationRecoveryScheduler notificationRecoveryScheduler; + + @Autowired + private NotificationDeviceLogRepository notificationDeviceLogRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private FCMTokenRepository fcmTokenRepository; + + @Autowired + private NotificationService notificationService; + + @Autowired + private FirebaseMessaging firebaseMessaging; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + private ErrorNotificationService errorNotificationService; + + private Member receiver; + private Member actor; + + @BeforeEach + void setUp() { + receiver = memberRepository.save(MemberFixture.createWithoutId()); + actor = memberRepository.save(MemberFixture.createWithoutId()); + circuitBreakerRegistry.circuitBreaker("fcm").transitionToClosedState(); + } + + @Test + void FAILED_상태의_로그를_재전송한다() throws FirebaseMessagingException { + // given + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); + + NotificationDto message = NotificationDtoFixture.builder() + .receiverId(receiver.getId()) + .actorId(actor.getId()) + .build(); + List dtos = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long logId = dtos.get(0).getNotificationDeviceLogId(); + + NotificationDeviceLog log = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + log.fail("Test Failure"); + notificationDeviceLogRepository.save(log); + + given(firebaseMessaging.send(any(Message.class))).willReturn("message-id"); + + // when: 현재 시간보다 20분 뒤를 기준으로 재전송 시도 (10분 대기 조건 충족) + notificationRecoveryScheduler.retryFailedNotifications(java.time.LocalDateTime.now().plusMinutes(20)); + + // then + NotificationDeviceLog updated = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + assertThat(updated.getStatus()).isEqualTo(FCMSendStatus.SENT); + verify(firebaseMessaging).send(any(Message.class)); + } + + @Test + void SENT_상태의_로그는_재전송하지_않는다() throws FirebaseMessagingException { + // given + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); + + NotificationDto message = NotificationDtoFixture.builder() + .receiverId(receiver.getId()) + .actorId(actor.getId()) + .build(); + List dtos = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long logId = dtos.get(0).getNotificationDeviceLogId(); + + NotificationDeviceLog log = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + log.succeed(); + notificationDeviceLogRepository.save(log); + + // when + notificationRecoveryScheduler.retryFailedNotifications(java.time.LocalDateTime.now().plusMinutes(20)); + + // then + NotificationDeviceLog updated = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + verify(firebaseMessaging, never()).send(any()); + + assertThat(updated.getStatus()).isEqualTo(FCMSendStatus.SENT); + } + + @Test + void 재시도_시_retryCount가_증가한다() throws FirebaseMessagingException { + // given + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); + + NotificationDto message = NotificationDtoFixture.builder() + .receiverId(receiver.getId()) + .actorId(actor.getId()) + .build(); + List dtos = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long logId = dtos.get(0).getNotificationDeviceLogId(); + + // Manually Fail + NotificationDeviceLog log = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + log.fail("Test Fail"); + notificationDeviceLogRepository.save(log); + + // Simulate Send Failure + given(firebaseMessaging.send(any(Message.class))).willThrow(new RuntimeException("FCM Error")); + + // when + notificationRecoveryScheduler.retryFailedNotifications(java.time.LocalDateTime.now().plusMinutes(20)); + + // then + NotificationDeviceLog updated = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + assertThat(updated.getStatus()).isEqualTo(FCMSendStatus.FAILED); + assertThat(updated.getRetryCount()).isEqualTo(1); + } + + @Test + void 재시도_횟수가_3회_이상인_로그는_재전송하지_않고_관리자_알림을_보낸다() { + // given + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); + + NotificationDto message = NotificationDtoFixture.builder() + .receiverId(receiver.getId()) + .actorId(actor.getId()) + .build(); + List dtos = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long logId = dtos.get(0).getNotificationDeviceLogId(); + + // Set RetryCount = 3 and Status = FAILED + NotificationDeviceLog log = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + for (int i = 0; i < 3; i++) { + log.incrementRetryCount(); + } + log.fail("Max Retry Fail"); + notificationDeviceLogRepository.save(log); + + // when + notificationRecoveryScheduler.retryFailedNotifications(java.time.LocalDateTime.now().plusMinutes(20)); + + // then + verify(errorNotificationService).sendErrorNotification(any(AsyncErrorNotificationDto.class)); + NotificationDeviceLog updated = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + assertThat(updated.getRetryCount()).isEqualTo(3); + assertThat(updated.getStatus()).isEqualTo(FCMSendStatus.RETRY_LIMIT_EXCEEDED); + } +} diff --git a/src/test/java/book/book/notification/service/FCMServiceTest.java b/src/test/java/book/book/notification/service/FCMServiceTest.java index d79e0a03..eda69da3 100644 --- a/src/test/java/book/book/notification/service/FCMServiceTest.java +++ b/src/test/java/book/book/notification/service/FCMServiceTest.java @@ -1,13 +1,10 @@ package book.book.notification.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import book.book.common.CustomException; -import book.book.common.ErrorCode; import book.book.config.IntegrationTest; import book.book.member.entity.Member; import book.book.member.fixture.MemberFixture; @@ -16,11 +13,10 @@ import book.book.notification.domain.FCMToken; import book.book.notification.domain.NotificationType; import book.book.notification.dto.FCMTokenCreateRequest; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.external.FirebaseClient; import book.book.notification.repository.FCMTokenRepository; import com.google.firebase.messaging.Message; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -52,17 +48,13 @@ void setUp() { } @Test - void FCM_토큰이_있으면_메시지를_전송한다() { + void 유효한_FCM_토큰이_포함된_알림을_전송한다() { // given String fcmToken = "test-fcm-token"; - fcmTokenRepository.save(new FCMToken(memberId, fcmToken, DeviceType.ANDROID)); - - NotificationMessage message = NotificationMessage.builder() - .receiverId(memberId) + NotificationDto message = NotificationDto.builder() + .fcmToken(fcmToken) // Token is now required in DTO .title("테스트 알림") .content("테스트 내용") - .notificationType(NotificationType.LIKE) - .metadata(Map.of("diaryId", 123L)) .build(); when(firebaseClient.send(any(Message.class))).thenReturn("message-id"); @@ -74,21 +66,14 @@ void setUp() { verify(firebaseClient).send(any(Message.class)); } - @Test - void FCM_토큰이_없으면_예외가_발생한다() { - // given - NotificationMessage message = NotificationMessage.builder() - .receiverId(memberId) - .title("테스트 알림") - .content("테스트 내용") - .notificationType(NotificationType.LIKE) - .build(); - - // when & then - assertThatThrownBy(() -> fcmService.send(message)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND_FCMTOKEN); - } + // send(NotificationDto) does NOT check DB or throw NOT_FOUND_FCMTOKEN anymore. + // It assumes the caller provided a token. If token is missing in DTO, + // Message.builder() might fail or it might send null. + // Verification of "Token Missing" is done by caller or logic ensuring DTO has + // token. + // We can remove "FCM_토큰이_없으면_예외가_발생한다" or update it to verify behavior when DTO + // has no token (if applicable). + // For now, removing it as FCMService is a dumb sender. @Test void 메시지에_메타데이터가_포함되어_전송된다() { @@ -96,18 +81,12 @@ void setUp() { String fcmToken = "test-fcm-token"; fcmTokenRepository.save(new FCMToken(memberId, fcmToken, DeviceType.IOS)); - Map metadata = Map.of( - "diaryId", 123L, - "commentId", 456L - ); - - NotificationMessage message = NotificationMessage.builder() - .receiverId(memberId) - .notificationId(1L) + NotificationDto message = NotificationDto.builder() + .fcmToken(fcmToken) .title("테스트 알림") .content("테스트 내용") .notificationType(NotificationType.COMMENT) - .metadata(metadata) + .metadata("{\"diaryId\": 123, \"commentId\": 456}") .build(); when(firebaseClient.send(any(Message.class))).thenReturn("message-id"); @@ -129,12 +108,10 @@ void setUp() { String fcmToken = "test-fcm-token"; fcmTokenRepository.save(new FCMToken(memberId, fcmToken, DeviceType.ANDROID)); - NotificationMessage message = NotificationMessage.builder() - .receiverId(memberId) + NotificationDto message = NotificationDto.builder() + .fcmToken(fcmToken) .title("테스트 알림") .content("테스트 내용") - .notificationType(NotificationType.LIKE) - .metadata(null) .build(); when(firebaseClient.send(any(Message.class))).thenReturn("message-id"); @@ -212,28 +189,7 @@ void setUp() { assertThat(ipadTokenEntity.getDeviceType()).isEqualTo(DeviceType.IOS); } - @Test - void 여러_디바이스를_가진_사용자에게_알림을_전송하면_모든_디바이스로_전송된다() { - // given - String androidToken = "android-token"; - String iosToken = "ios-token"; - - fcmTokenRepository.save(new FCMToken(memberId, androidToken, DeviceType.ANDROID)); - fcmTokenRepository.save(new FCMToken(memberId, iosToken, DeviceType.IOS)); - - NotificationMessage message = NotificationMessage.builder() - .receiverId(memberId) - .title("테스트 알림") - .content("테스트 내용") - .notificationType(NotificationType.LIKE) - .build(); + // "여러_디바이스를_가진_사용자에게..." test removed because FCMService no longer handles + // iteration. - when(firebaseClient.send(any(Message.class))).thenReturn("message-id"); - - // when - fcmService.send(message); - - // then: 2번 호출됨 (Android와 iOS 각각) - verify(firebaseClient, org.mockito.Mockito.times(2)).send(any(Message.class)); - } } diff --git a/src/test/java/book/book/notification/service/NotificationFacadeTest.java b/src/test/java/book/book/notification/service/NotificationFacadeTest.java index d268d288..7fb14817 100644 --- a/src/test/java/book/book/notification/service/NotificationFacadeTest.java +++ b/src/test/java/book/book/notification/service/NotificationFacadeTest.java @@ -12,14 +12,16 @@ import book.book.member.fixture.MemberFixture; import book.book.member.repository.MemberRepository; import book.book.notification.domain.FCMSendStatus; -import book.book.notification.domain.FCMToken; import book.book.notification.domain.Notification; -import book.book.notification.dto.NotificationMessage; -import book.book.notification.external.FirebaseClient; +import book.book.notification.domain.NotificationDeviceLog; +import book.book.notification.dto.NotificationDto; import book.book.notification.fixture.FCMTokenFixture; -import book.book.notification.fixture.NotificationMessageFixture; +import book.book.notification.fixture.NotificationDtoFixture; import book.book.notification.repository.FCMTokenRepository; +import book.book.notification.repository.NotificationDeviceLogRepository; import book.book.notification.repository.NotificationRepository; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -42,7 +44,10 @@ class NotificationFacadeTest { private FCMTokenRepository fcmTokenRepository; @Autowired - private FirebaseClient firebaseClient; + private NotificationDeviceLogRepository notificationDeviceLogRepository; + + @Autowired + private FirebaseMessaging firebaseMessaging; private Member receiver; private Member actor; @@ -58,16 +63,16 @@ void setUp() { } @Test - void 알림을_DB에_저장하고_FCM으로_전송한다() { + void 알림을_DB에_저장하고_FCM으로_전송한다() throws FirebaseMessagingException { // given fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - NotificationMessage message = NotificationMessageFixture.builder() + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); + given(firebaseMessaging.send(any(Message.class))).willReturn("message-id"); // when notificationFacade.saveAndSend(message); @@ -75,38 +80,45 @@ void setUp() { // then List notifications = notificationRepository.findAll(); assertThat(notifications).hasSize(1); - verify(firebaseClient).send(any(Message.class)); + verify(firebaseMessaging).send(any(Message.class)); } @Test - void FCM_전송_성공_시_알림_상태가_SENT로_변경된다() { + void FCM_전송_성공_시_알림_상태가_SENT로_변경된다() throws FirebaseMessagingException { // given fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - NotificationMessage message = NotificationMessageFixture.builder() + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); + given(firebaseMessaging.send(any(Message.class))).willReturn("message-id"); // when notificationFacade.saveAndSend(message); // then Notification saved = notificationRepository.findAll().get(0); - assertThat(saved.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); + // Notification itself doesn't have status anymore + + List logs = notificationDeviceLogRepository.findAll(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(FCMSendStatus.SENT); } @Test - void FCM_전송_실패_시_알림은_DB에_저장되고_상태가_FAILED로_변경된다() { - // given: FCM 토큰이 없어서 전송 실패 - NotificationMessage message = NotificationMessageFixture.builder() + void FCM_전송_실패_시_알림은_DB에_저장되고_상태가_FAILED로_변경된다() throws FirebaseMessagingException { + // given + // We need a token for logic to proceed to send + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); + + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); - given(firebaseClient.send(any(Message.class))).willThrow(new CustomException(ErrorCode.FCM_SEND_ERROR)); + given(firebaseMessaging.send(any(Message.class))).willThrow(new CustomException(ErrorCode.FCM_SEND_ERROR)); // when: 예외가 발생하지 않고 정상 처리됨 notificationFacade.saveAndSend(message); @@ -115,22 +127,23 @@ void setUp() { List notifications = notificationRepository.findAll(); assertThat(notifications).hasSize(1); - Notification saved = notifications.get(0); - assertThat(saved.getReceiver().getId()).isEqualTo(receiverId); - assertThat(saved.getFcmSendStatus()).isEqualTo(FCMSendStatus.FAILED); + List logs = notificationDeviceLogRepository.findAll(); + assertThat(logs).hasSize(1); + NotificationDeviceLog savedLog = logs.get(0); + assertThat(savedLog.getStatus()).isEqualTo(FCMSendStatus.FAILED); } @Test - void 트랜잭션_분리로_DB_저장은_커밋되고_FCM_전송은_별도_트랜잭션에서_처리된다() { + void 트랜잭션_분리로_DB_저장은_커밋되고_FCM_전송은_별도_트랜잭션에서_처리된다() throws FirebaseMessagingException { // given fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - NotificationMessage message = NotificationMessageFixture.builder() + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); + given(firebaseMessaging.send(any(Message.class))).willReturn("message-id"); // when notificationFacade.saveAndSend(message); @@ -139,22 +152,22 @@ void setUp() { List notifications = notificationRepository.findAll(); assertThat(notifications).hasSize(1); - Notification saved = notifications.get(0); - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); + List logs = notificationDeviceLogRepository.findAll(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(FCMSendStatus.SENT); } @Test - void 알림_저장_후_notificationId가_설정된다() { + void 알림_저장_후_notificationId가_설정된다() throws FirebaseMessagingException { // given fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - NotificationMessage message = NotificationMessageFixture.builder() + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); + given(firebaseMessaging.send(any(Message.class))).willReturn("message-id"); // when notificationFacade.saveAndSend(message); diff --git a/src/test/java/book/book/notification/service/NotificationRecoverySchedulerTest.java b/src/test/java/book/book/notification/service/NotificationRecoverySchedulerTest.java deleted file mode 100644 index 71182896..00000000 --- a/src/test/java/book/book/notification/service/NotificationRecoverySchedulerTest.java +++ /dev/null @@ -1,236 +0,0 @@ -package book.book.notification.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import book.book.config.IntegrationTest; -import book.book.member.entity.Member; -import book.book.member.fixture.MemberFixture; -import book.book.member.repository.MemberRepository; -import book.book.notification.domain.FCMSendStatus; -import book.book.notification.domain.FCMToken; -import book.book.notification.domain.Notification; -import book.book.notification.domain.NotificationType; -import book.book.notification.external.FirebaseClient; -import book.book.notification.fixture.FCMTokenFixture; -import book.book.notification.repository.FCMTokenRepository; -import book.book.notification.repository.NotificationRepository; -import com.google.firebase.messaging.Message; -import java.time.LocalDateTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@IntegrationTest -class NotificationRecoverySchedulerTest { - - @Autowired - private NotificationRecoveryScheduler notificationRecoveryScheduler; - - @Autowired - private NotificationRepository notificationRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private FCMTokenRepository fcmTokenRepository; - - @Autowired - private FirebaseClient firebaseClient; - - private Member receiver; - private Member actor; - - @BeforeEach - void setUp() { - receiver = memberRepository.save(MemberFixture.createWithoutId()); - actor = memberRepository.save(MemberFixture.createWithoutId()); - } - - @Test - void FAILED_상태의_알림을_재전송한다() { - // given - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification failedNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("테스트 알림") - .fcmSendStatus(FCMSendStatus.FAILED) - .build()); - - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); - - // when - notificationRecoveryScheduler.retryFailedNotifications(); - - // then - Notification updated = notificationRepository.findById(failedNotification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); - verify(firebaseClient).send(any(Message.class)); - } - - @Test - void SENT_상태의_알림은_재전송하지_않는다() { - // given - Notification sentNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.LIKE) - .message("테스트 알림") - .fcmSendStatus(FCMSendStatus.SENT) - .build()); - - // when - notificationRecoveryScheduler.retryFailedNotifications(); - - // then - Notification updated = notificationRepository.findById(sentNotification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); - verify(firebaseClient, times(0)).send(any(Message.class)); - } - - @Test - void 시의성_6시간이_지난_FAILED_알림은_재전송하지_않고_삭제한다() { - // given: FAILED 알림 생성 - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification oldFailedNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("오래된 알림") - .fcmSendStatus(FCMSendStatus.FAILED) - .build()); - // 생성 시간을 7시간 전으로 강제 설정 (JPA Auditing 덮어쓰기 위해 native query 필요할 수도 있으나, 여기선 Test - // entity 사용하거나 flush 후 조작 등 필요. - // 하지만 BaseTimeEntity는 보통 createdDate가 updatable=false임. - // IntegrationTest에서는 Repository를 통해 저장하므로 createdDate가 자동 설정됨. - // 테스트 메서드의 파라미터로 'currentTime'을 넘기는 방식이므로, - // Notification을 '현재' 만들고, 스케줄러에는 '7시간 후'를 현재 시각으로 전달하면 됨. - - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); - - // when: 알림 생성 시점(T) 기준으로 T+7시간을 '현재'로 가정하고 스케줄러 실행 - LocalDateTime sevenHoursLater = LocalDateTime.now().plusHours(7); - // 주의: Notification이 저장된 시점은 LocalDateTime.now()임. - // 스케줄러에 전달하는 currentTime이 sevenHoursLater. - // cutoff = 7h later - 6h = 1h later. - // createdDate(now) < cutoff(1h later) -> True. - // 따라서 삭제되어야 함. - - notificationRecoveryScheduler.retryFailedNotifications(sevenHoursLater); - - // then: 삭제되었는지 확인 - assertThat(notificationRepository.findById(oldFailedNotification.getId())).isEmpty(); - verify(firebaseClient, times(0)).send(any(Message.class)); - } - - @Test - void PENDING_상태의_알림이_10분_이상_지체되면_재전송한다() { - // given: PENDING 알림 생성 - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification pendingNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("지연된 알림") - .fcmSendStatus(FCMSendStatus.PENDING) - .build()); - - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); - - // when: 알림 생성 15분 후 시점으로 실행 (10분 < 15분 < 6시간) - LocalDateTime fifteenMinutesLater = LocalDateTime.now().plusMinutes(15); - notificationRecoveryScheduler.retryFailedNotifications(fifteenMinutesLater); - - // then: 재전송되어 SENT로 변경됨 - Notification updated = notificationRepository.findById(pendingNotification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); - verify(firebaseClient).send(any(Message.class)); - } - - @Test - void PENDING_상태의_알림이_10분_미만이면_재전송하지_않는다() { - // given: PENDING 알림 생성 - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification freshPendingNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("갓 생성된 알림") - .fcmSendStatus(FCMSendStatus.PENDING) - .build()); - - // when: 알림 생성 5분 후 시점으로 실행 (5분 < 10분) - LocalDateTime fiveMinutesLater = LocalDateTime.now().plusMinutes(5); - notificationRecoveryScheduler.retryFailedNotifications(fiveMinutesLater); - - // then: 여전히 PENDING 상태이고 전송 시도 안 함 - Notification updated = notificationRepository.findById(freshPendingNotification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.PENDING); - verify(firebaseClient, times(0)).send(any(Message.class)); - } - - @Test - void 시의성_6시간이_지난_PENDING_알림은_삭제한다() { - // given: PENDING 알림 생성 - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification oldPendingNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("오래된 대기 알림") - .fcmSendStatus(FCMSendStatus.PENDING) - .build()); - - // when: 7시간 후 실행 - LocalDateTime sevenHoursLater = LocalDateTime.now().plusHours(7); - notificationRecoveryScheduler.retryFailedNotifications(sevenHoursLater); - - // then: 삭제 확인 - assertThat(notificationRepository.findById(oldPendingNotification.getId())).isEmpty(); - } - - @Test - void 적시성_6시간_이내의_FAILED_알림은_재전송한다() { - // given: FAILED 알림 생성 - fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiver.getId()).build()); - - Notification recentFailedNotification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.COMMENT) - .message("최근 알림") - .fcmSendStatus(FCMSendStatus.FAILED) - .build()); - - given(firebaseClient.send(any(Message.class))).willReturn("message-id"); - - // when: 알림 생성 5시간 후의 시점으로 재전송 시도 (여전히 적시성 있음) - // when: 알림 생성 5시간 후의 시점으로 재전송 시도 (여전히 적시성 있음) - LocalDateTime fiveHoursLater = LocalDateTime.now().plusHours(5); - notificationRecoveryScheduler.retryFailedNotifications(fiveHoursLater); - - // then: 재전송되어 SENT로 변경됨 (6시간 cutoff 이내) - Notification updated = notificationRepository.findById(recentFailedNotification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); - verify(firebaseClient).send(any(Message.class)); - } -} diff --git a/src/test/java/book/book/notification/service/NotificationServiceTest.java b/src/test/java/book/book/notification/service/NotificationServiceTest.java index 2792095c..5da85b8a 100644 --- a/src/test/java/book/book/notification/service/NotificationServiceTest.java +++ b/src/test/java/book/book/notification/service/NotificationServiceTest.java @@ -1,29 +1,23 @@ package book.book.notification.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; -import book.book.common.CustomException; -import book.book.common.ErrorCode; import book.book.config.IntegrationTest; import book.book.member.entity.Member; import book.book.member.fixture.MemberFixture; import book.book.member.repository.MemberRepository; -import book.book.notification.domain.FCMSendStatus; -import book.book.notification.domain.FCMToken; import book.book.notification.domain.Notification; +import book.book.notification.domain.NotificationDeviceLog; import book.book.notification.domain.NotificationType; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.external.FirebaseClient; -import book.book.notification.fixture.NotificationMessageFixture; +import book.book.notification.fixture.FCMTokenFixture; +import book.book.notification.fixture.NotificationDtoFixture; import book.book.notification.repository.FCMTokenRepository; +import book.book.notification.repository.NotificationDeviceLogRepository; import book.book.notification.repository.NotificationRepository; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import java.util.Map; +import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -37,6 +31,9 @@ class NotificationServiceTest { @Autowired private NotificationRepository notificationRepository; + @Autowired + private NotificationDeviceLogRepository notificationDeviceLogRepository; + @Autowired private MemberRepository memberRepository; @@ -60,49 +57,53 @@ void setUp() { } @Test - void 알림을_DB에_저장한다() { + void 알림을_저장하고_DeviceLog를_생성한다() { // given - NotificationMessage message = NotificationMessageFixture.builder() + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); + + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); // when - Notification saved = notificationService.saveNotification(message); + List results = notificationService.saveNotificationAndNotificationDeviceLogs(message); // then - assertThat(saved).isNotNull(); - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getReceiver().getId()).isEqualTo(receiverId); - assertThat(saved.getActor().getId()).isEqualTo(actorId); - assertThat(saved.getFcmSendStatus()).isEqualTo(FCMSendStatus.PENDING); + assertThat(results).hasSize(1); + NotificationDto result = results.get(0); + assertThat(result.getNotificationId()).isNotNull(); + assertThat(result.getNotificationDeviceLogId()).isNotNull(); + assertThat(result.getFcmTokenId()).isNotNull(); } @Test void receiver와_actor_정보가_올바르게_저장된다() { // given - NotificationMessage message = NotificationMessageFixture.builder() + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); + + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .build(); // when - Notification saved = notificationService.saveNotification(message); + List notificationDtos = notificationService + .saveNotificationAndNotificationDeviceLogs(message); // then - assertThat(saved.getReceiver().getId()).isEqualTo(receiverId); - assertThat(saved.getActor().getId()).isEqualTo(actorId); + assertThat(notificationDtos.getFirst().getReceiverId()).isEqualTo(receiverId); + assertThat(notificationDtos.getFirst().getActorId()).isEqualTo(actorId); } @Test void metadata가_JSON으로_변환되어_저장된다() { // given - Map metadata = Map.of( - "diaryId", 123L, - "commentId", 456L - ); + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - NotificationMessage message = NotificationMessage.builder() + String metadata = "{\"diaryId\": 123, \"commentId\": 456}"; + + NotificationDto message = NotificationDto.builder() .receiverId(receiverId) .actorId(actorId) .title("테스트 알림") @@ -112,7 +113,10 @@ void setUp() { .build(); // when - Notification saved = notificationService.saveNotification(message); + List results = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long notificationId = results.get(0).getNotificationId(); + + Notification saved = notificationRepository.findById(notificationId).orElseThrow(); // then assertThat(saved.getMetadata()).isNotNull(); @@ -125,78 +129,56 @@ void setUp() { @Test void metadata가_null이면_null로_저장된다() { // given - NotificationMessage message = NotificationMessageFixture.builder() + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); + + NotificationDto message = NotificationDtoFixture.builder() .receiverId(receiverId) .actorId(actorId) .metadata(null) .build(); // when - Notification saved = notificationService.saveNotification(message); + List results = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long notificationId = results.get(0).getNotificationId(); + + Notification saved = notificationRepository.findById(notificationId).orElseThrow(); // then assertThat(saved.getMetadata()).isNull(); } @Test - void 알림_상태를_SENT로_업데이트한다() { + void 재시도_대상_메시지를_찾고_상태를_업데이트한다() { // given - Notification notification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.LIKE) - .message("테스트 알림") - .build() - ); + fcmTokenRepository.save(FCMTokenFixture.builderWithoutId().userId(receiverId).build()); - // when - notificationService.updateStatusToSent(notification.getId()); + NotificationDto message = NotificationDtoFixture.builder() + .receiverId(receiverId) + .actorId(actorId) + .build(); - // then - Notification updated = notificationRepository.findById(notification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.SENT); - } + List logs = notificationService.saveNotificationAndNotificationDeviceLogs(message); + Long logId = logs.get(0).getNotificationDeviceLogId(); - @Test - void 알림_상태를_FAILED로_업데이트한다() { - // given - Notification notification = notificationRepository.save( - Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.LIKE) - .message("테스트 알림") - .build() - ); + NotificationDeviceLog deviceLog = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + deviceLog.fail("Initial Failure"); + notificationDeviceLogRepository.save(deviceLog); // when - notificationService.updateStatusToFailed(notification.getId()); + LocalDateTime now = java.time.LocalDateTime.now(); + LocalDateTime cutoffTime = now.minusHours(1); + LocalDateTime pendingThreshold = now.plusHours(1); - // then - Notification updated = notificationRepository.findById(notification.getId()).orElseThrow(); - assertThat(updated.getFcmSendStatus()).isEqualTo(FCMSendStatus.FAILED); - } - - @Test - void 알림_읽음_처리가_올바르게_동작한다() { - // given - Notification notification = Notification.builder() - .receiver(receiver) - .actor(actor) - .notificationType(NotificationType.LIKE) - .message("테스트 알림") - .build(); - Notification saved = notificationRepository.save(notification); - Long notificationId = saved.getId(); - - assertThat(saved.isRead()).isFalse(); - - // when - notificationService.markAsRead(notificationId); + List retryMessages = notificationService.findRetryMessages(cutoffTime, + pendingThreshold); // then - Notification updated = notificationRepository.findById(notificationId).orElseThrow(); - assertThat(updated.isRead()).isTrue(); + assertThat(retryMessages).hasSize(1); + NotificationDto retryMessage = retryMessages.get(0); + assertThat(retryMessage.getNotificationDeviceLogId()).isEqualTo(logId); + + // Verify retry count incremented + NotificationDeviceLog updatedLog = notificationDeviceLogRepository.findByIdOrElseThrow(logId); + assertThat(updatedLog.getRetryCount()).isEqualTo(1); } } diff --git a/src/test/java/book/book/quiz/event/QuizEventListenerTest.java b/src/test/java/book/book/quiz/event/QuizEventListenerTest.java index 0445b07f..313819fc 100644 --- a/src/test/java/book/book/quiz/event/QuizEventListenerTest.java +++ b/src/test/java/book/book/quiz/event/QuizEventListenerTest.java @@ -1,6 +1,5 @@ package book.book.quiz.event; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; @@ -15,15 +14,13 @@ import book.book.member.entity.Member; import book.book.member.fixture.MemberFixture; import book.book.member.repository.MemberRepository; -import book.book.notification.dto.NotificationMessage; +import book.book.notification.dto.NotificationDto; import book.book.notification.event.NotificationMessageFactory; import book.book.notification.service.NotificationFacade; -import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -73,19 +70,19 @@ void setUp() { given(challengeRepository.findByMemberAndBookAndCompletedFalseAndAbandonedFalse(member, book)) .willReturn(Optional.of(challenge)); - NotificationMessage expectedMessage = NotificationMessage.builder() + NotificationDto expectedMessage = NotificationDto.builder() .receiverId(member.getId()) .title("퀴즈 생성 완료") .content("'" + book.getTitle() + "' 책의 퀴즈가 생성되었습니다.") - .metadata(Map.of("bookId", book.getId(), "challengeId", challenge.getId())) + .metadata("{\"bookId\":\"" + book.getId() + "\",\"challengeId\":\"" + challenge.getId() + + "\"}") .build(); given(messageFactory.buildQuizCompletedNotification( member.getId(), book.getTitle(), book.getId(), - challenge.getId() - )).willReturn(expectedMessage); + challenge.getId())).willReturn(expectedMessage); // when quizEventListener.handleQuizCreatedNotification(event); @@ -98,8 +95,7 @@ void setUp() { member.getId(), book.getTitle(), book.getId(), - challenge.getId() - ); + challenge.getId()); verify(notificationFacade).saveAndSend(expectedMessage); } @@ -133,7 +129,7 @@ void setUp() { given(bookRepository.findByIdOrElseThrow(book.getId())).willReturn(book); given(memberRepository.findByIdOrElseThrow(member.getId())).willReturn(member); given(challengeRepository.findByMemberAndBookAndCompletedFalseAndAbandonedFalse(member, book)) - .willReturn(Optional.empty()); // 완료된 챌린지는 조회되지 않음 + .willReturn(Optional.empty()); // 완료된 챌린지는 조회되지 않음 // when quizEventListener.handleQuizCreatedNotification(event); @@ -151,7 +147,7 @@ void setUp() { given(bookRepository.findByIdOrElseThrow(book.getId())).willReturn(book); given(memberRepository.findByIdOrElseThrow(member.getId())).willReturn(member); given(challengeRepository.findByMemberAndBookAndCompletedFalseAndAbandonedFalse(member, book)) - .willReturn(Optional.empty()); // 포기한 챌린지는 조회되지 않음 + .willReturn(Optional.empty()); // 포기한 챌린지는 조회되지 않음 // when quizEventListener.handleQuizCreatedNotification(event);