diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java index 35f3d425..f0423225 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java @@ -6,6 +6,7 @@ import org.devkor.apu.saerok_server.domain.freeboard.api.dto.request.UpdateFreeBoardPostCommentRequest; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.CreateFreeBoardPostCommentResponse; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.UpdateFreeBoardPostCommentResponse; +import org.devkor.apu.saerok_server.domain.freeboard.application.event.FreeBoardNotificationEvent; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment; import org.devkor.apu.saerok_server.domain.freeboard.core.repository.FreeBoardPostCommentRepository; @@ -14,6 +15,7 @@ import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.ForbiddenException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service @@ -24,6 +26,7 @@ public class FreeBoardPostCommentCommandService { private final FreeBoardPostCommentRepository commentRepository; private final FreeBoardPostRepository postRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* 댓글 작성 */ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId, @@ -35,8 +38,9 @@ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId .orElseThrow(() -> new NotFoundException("존재하지 않는 게시글 id예요")); FreeBoardPostComment comment; + FreeBoardPostComment parentComment = null; if (req.parentId() != null) { - FreeBoardPostComment parentComment = commentRepository.findById(req.parentId()) + parentComment = commentRepository.findById(req.parentId()) .orElseThrow(() -> new NotFoundException("존재하지 않는 댓글 id예요")); if (!parentComment.getPost().getId().equals(postId)) { @@ -55,6 +59,15 @@ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId } commentRepository.save(comment); + + eventPublisher.publishEvent(new FreeBoardNotificationEvent.CommentCreated( + userId, user.getNickname(), + postId, post.getUser().getId(), + parentComment != null ? parentComment.getId() : null, + parentComment != null ? parentComment.getUser().getId() : null, + req.content() + )); + return new CreateFreeBoardPostCommentResponse(comment.getId()); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java new file mode 100644 index 00000000..6929405e --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +public sealed interface FreeBoardNotificationEvent { + + record CommentCreated( + Long actorId, String actorNickname, + Long postId, Long postOwnerId, + Long parentCommentId, Long parentCommentOwnerId, + String commentContent + ) implements FreeBoardNotificationEvent {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java new file mode 100644 index 00000000..3592de9c --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java @@ -0,0 +1,63 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FreeBoardNotificationWorker { + + private final NotifyActionDsl notifyAction; + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(FreeBoardNotificationEvent.CommentCreated event) { + try { + Actor actor = Actor.of(event.actorId(), event.actorNickname()); + + if (event.parentCommentId() != null) { + // 대댓글: 원댓글 작성자에게 REPLY 알림 + if (!event.parentCommentOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.freeBoardComment(event.parentCommentId())) + .did(ActionKind.REPLY) + .comment(event.commentContent()) + .to(event.parentCommentOwnerId()); + } + // 게시글 소유자에게 COMMENT 알림 (원댓글 작성자와 다른 경우에만) + if (!event.postOwnerId().equals(event.actorId()) + && !event.postOwnerId().equals(event.parentCommentOwnerId())) { + notifyAction + .by(actor) + .on(Target.freeBoardPost(event.postId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.postOwnerId()); + } + } else { + // 원댓글: 게시글 소유자에게 COMMENT 알림 + if (!event.postOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.freeBoardPost(event.postId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.postOwnerId()); + } + } + } catch (Exception e) { + log.error("Failed to send freeboard comment notification: postId={}, actorId={}", + event.postId(), event.actorId(), e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java index 741ac715..a81ac2a7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java @@ -18,12 +18,16 @@ public class DelegatingTargetMetadataAdapter implements TargetMetadataPort { private final CollectionTargetMetadataAdapter collectionAdapter; private final CommentTargetMetadataAdapter commentAdapter; + private final FreeBoardPostTargetMetadataAdapter freeBoardPostAdapter; + private final FreeBoardCommentTargetMetadataAdapter freeBoardCommentAdapter; @Override public Map enrich(Target target, Map baseExtras) { return switch (target.type()) { case COLLECTION -> collectionAdapter.enrich(target, baseExtras); case COMMENT -> commentAdapter.enrich(target, baseExtras); + case FREE_BOARD_POST -> freeBoardPostAdapter.enrich(target, baseExtras); + case FREE_BOARD_COMMENT -> freeBoardCommentAdapter.enrich(target, baseExtras); }; } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java new file mode 100644 index 00000000..b98d3651 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java @@ -0,0 +1,38 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.freeboard.core.repository.FreeBoardPostCommentRepository; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * TargetType.FREE_BOARD_COMMENT 전용 메타데이터 어댑터.
+ * - extras.freeBoardCommentId
+ * - extras.freeBoardPostId (댓글이 속한 자유게시판 글) + */ +@Component +@RequiredArgsConstructor +public class FreeBoardCommentTargetMetadataAdapter implements TargetMetadataPort { + + private final FreeBoardPostCommentRepository commentRepository; + + @Override + public Map enrich(Target target, Map baseExtras) { + if (target.type() != TargetType.FREE_BOARD_COMMENT) { + return baseExtras != null ? baseExtras : Map.of(); + } + + Map extras = baseExtras != null ? new HashMap<>(baseExtras) : new HashMap<>(); + extras.put("freeBoardCommentId", target.id()); + + commentRepository.findById(target.id()) + .ifPresent(comment -> extras.put("freeBoardPostId", comment.getPost().getId())); + + return extras; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java new file mode 100644 index 00000000..9d3b0efb --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java @@ -0,0 +1,30 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * TargetType.FREE_BOARD_POST 전용 메타데이터 어댑터.
+ * - extras.freeBoardPostId + */ +@Component +@RequiredArgsConstructor +public class FreeBoardPostTargetMetadataAdapter implements TargetMetadataPort { + + @Override + public Map enrich(Target target, Map baseExtras) { + if (target.type() != TargetType.FREE_BOARD_POST) { + return baseExtras != null ? baseExtras : Map.of(); + } + + Map extras = baseExtras != null ? new HashMap<>(baseExtras) : new HashMap<>(); + extras.put("freeBoardPostId", target.id()); + return extras; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java index 5f827c91..fc016b56 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java @@ -103,6 +103,8 @@ public void to(Long recipientId) { var notificationSubject = switch (target.type()) { case COLLECTION -> NotificationSubject.COLLECTION; case COMMENT -> NotificationSubject.COMMENT; + case FREE_BOARD_POST -> NotificationSubject.FREE_BOARD_POST; + case FREE_BOARD_COMMENT -> NotificationSubject.FREE_BOARD_COMMENT; }; var notificationAction = switch (action) { @@ -112,9 +114,15 @@ public void to(Long recipientId) { case SUGGEST_BIRD_ID -> NotificationAction.SUGGEST_BIRD_ID; }; - Long relatedId = target.type() == TargetType.COMMENT && extras.containsKey("collectionId") - ? (Long) extras.get("collectionId") - : target.id(); + Long relatedId = switch (target.type()) { + case COMMENT -> extras.containsKey("collectionId") + ? (Long) extras.get("collectionId") + : target.id(); + case FREE_BOARD_COMMENT -> extras.containsKey("freeBoardPostId") + ? (Long) extras.get("freeBoardPostId") + : target.id(); + default -> target.id(); + }; publisher.push( new ActionNotificationPayload( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java index 29de4cb7..04927c28 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java @@ -3,4 +3,6 @@ public record Target(TargetType type, Long id) { public static Target collection(Long id) { return new Target(TargetType.COLLECTION, id); } public static Target comment(Long id) { return new Target(TargetType.COMMENT, id); } + public static Target freeBoardPost(Long id) { return new Target(TargetType.FREE_BOARD_POST, id); } + public static Target freeBoardComment(Long id) { return new Target(TargetType.FREE_BOARD_COMMENT, id); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java index d52012cc..820f4292 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java @@ -3,4 +3,6 @@ public enum TargetType { COLLECTION, COMMENT, + FREE_BOARD_POST, + FREE_BOARD_COMMENT, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java index e3486dd1..4dafc7e3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.notification.core.entity; public enum NotificationSubject { - COLLECTION, // "내 컬렉션에 대한 활동" - COMMENT, // "내 댓글에 대한 활동" + COLLECTION, // "내 컬렉션에 대한 활동" + COMMENT, // "내 컬렉션 댓글에 대한 활동" + FREE_BOARD_POST, // "내 자유게시판 글에 대한 활동" + FREE_BOARD_COMMENT, // "내 자유게시판 댓글에 대한 활동" } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index 84f01bd8..ec4432cd 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -15,6 +15,8 @@ public enum NotificationType { COMMENTED_ON_COLLECTION, REPLIED_TO_COMMENT, SUGGESTED_BIRD_ID_ON_COLLECTION, + COMMENTED_ON_FREE_BOARD_POST, + REPLIED_TO_FREE_BOARD_COMMENT, // ---- System Notification Types ---- SYSTEM_PUBLISHED_ANNOUNCEMENT, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java index 56fd0ba3..38e70ab5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java @@ -24,6 +24,14 @@ public static NotificationType from(NotificationSubject subject, NotificationAct case REPLY -> NotificationType.REPLIED_TO_COMMENT; case LIKE, COMMENT, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for COMMENT subject"); }; + case FREE_BOARD_POST -> switch (action) { + case COMMENT -> NotificationType.COMMENTED_ON_FREE_BOARD_POST; + case LIKE, REPLY, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for FREE_BOARD_POST subject"); + }; + case FREE_BOARD_COMMENT -> switch (action) { + case REPLY -> NotificationType.REPLIED_TO_FREE_BOARD_COMMENT; + case LIKE, COMMENT, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for FREE_BOARD_COMMENT subject"); + }; }; } } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 516e772b..2f0657e7 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -20,6 +20,16 @@ notification-messages: push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" batch-push-title: "동정 의견 공유" batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" + COMMENTED_ON_FREE_BOARD_POST: + push-title: "{actorName}" + push-body: "나의 게시글에 댓글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 게시글에 댓글을 남겼어요." + REPLIED_TO_FREE_BOARD_COMMENT: + push-title: "{actorName}" + push-body: "나의 댓글에 답글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 댓글에 답글을 남겼어요." SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" push-body: "{body}" diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java new file mode 100644 index 00000000..7f1bde4a --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java @@ -0,0 +1,176 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class FreeBoardNotificationWorkerTest { + + @Mock private NotificationPublisher publisher; + + private FreeBoardNotificationWorker worker; + + @BeforeEach + void setUp() { + TargetMetadataPort metadataPort = (target, baseExtras) -> { + Map extras = baseExtras == null ? new HashMap<>() : new HashMap<>(baseExtras); + + if (target.type() == TargetType.FREE_BOARD_POST) { + extras.put("freeBoardPostId", target.id()); + } else if (target.type() == TargetType.FREE_BOARD_COMMENT) { + extras.put("freeBoardCommentId", target.id()); + extras.put("freeBoardPostId", 100L); + } + return extras; + }; + + worker = new FreeBoardNotificationWorker(new NotifyActionDsl(publisher, metadataPort)); + } + + @Test + @DisplayName("원댓글 알림은 게시글 소유자에게 하나 생성된다") + void handle_topLevelComment_generatesPostOwnerNotification() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "commenter", + 100L, 2L, + null, null, + "hello" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.subject()).isEqualTo(NotificationSubject.FREE_BOARD_POST); + assertThat(payload.action()).isEqualTo(NotificationAction.COMMENT); + assertThat(payload.type()).isEqualTo(NotificationType.COMMENTED_ON_FREE_BOARD_POST); + assertThat(payload.relatedId()).isEqualTo(100L); + assertThat(payload.extras()).containsEntry("freeBoardPostId", 100L); + assertThat(payload.extras()).containsEntry("comment", "hello"); + } + + @Test + @DisplayName("자기 게시글에 원댓글을 달면 알림이 생성되지 않는다") + void handle_selfComment_skipsNotifications() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "owner", + 100L, 1L, + null, null, + "self comment" + )); + + verifyNoInteractions(publisher); + } + + @Test + @DisplayName("대댓글 알림은 원댓글 작성자와 게시글 소유자에게 각각 생성된다") + void handle_replyComment_generatesTwoNotifications() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "replier", + 100L, 3L, + 200L, 2L, + "reply body" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher, times(2)).push(payloadCaptor.capture()); + + List payloads = payloadCaptor.getAllValues().stream() + .map(ActionNotificationPayload.class::cast) + .toList(); + + assertThat(payloads) + .extracting(ActionNotificationPayload::recipientId, ActionNotificationPayload::type) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(2L, NotificationType.REPLIED_TO_FREE_BOARD_COMMENT), + org.assertj.core.groups.Tuple.tuple(3L, NotificationType.COMMENTED_ON_FREE_BOARD_POST) + ); + + ActionNotificationPayload replyPayload = payloads.stream() + .filter(p -> p.type() == NotificationType.REPLIED_TO_FREE_BOARD_COMMENT) + .findFirst() + .orElseThrow(); + + assertThat(replyPayload.subject()).isEqualTo(NotificationSubject.FREE_BOARD_COMMENT); + assertThat(replyPayload.action()).isEqualTo(NotificationAction.REPLY); + assertThat(replyPayload.relatedId()).isEqualTo(100L); + assertThat(replyPayload.extras()).containsEntry("freeBoardCommentId", 200L); + assertThat(replyPayload.extras()).containsEntry("freeBoardPostId", 100L); + assertThat(replyPayload.extras()).containsEntry("comment", "reply body"); + } + + @Test + @DisplayName("대댓글: 게시글 소유자가 원댓글 작성자와 같으면 게시글 소유자 알림은 중복으로 생성하지 않는다") + void handle_replyWhenPostOwnerIsParentAuthor_generatesOnlyReplyNotification() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "replier", + 100L, 2L, + 200L, 2L, + "reply body" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.type()).isEqualTo(NotificationType.REPLIED_TO_FREE_BOARD_COMMENT); + } + + @Test + @DisplayName("대댓글: 본인이 자기 댓글에 자답해도 게시글 소유자에게는 알림이 간다") + void handle_selfReplyOnOwnComment_stillNotifiesPostOwner() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "self", + 100L, 3L, + 200L, 1L, + "self reply" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(3L); + assertThat(payload.type()).isEqualTo(NotificationType.COMMENTED_ON_FREE_BOARD_POST); + } + + @Test + @DisplayName("발송 중 예외가 나도 워커는 예외를 외부로 전파하지 않는다") + void handle_publisherFailure_swallowsException() { + doThrow(new IllegalStateException("push failed")).when(publisher).push(org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "commenter", + 100L, 2L, + null, null, + "boom" + ))).doesNotThrowAnyException(); + } +}