diff --git a/app-main/src/main/java/net/causw/app/main/domain/community/post/service/LikePostService.java b/app-main/src/main/java/net/causw/app/main/domain/community/post/service/LikePostService.java index 333e754f8..437dd2db8 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/community/post/service/LikePostService.java +++ b/app-main/src/main/java/net/causw/app/main/domain/community/post/service/LikePostService.java @@ -11,8 +11,10 @@ import net.causw.app.main.domain.community.post.service.implementation.PostReader; import net.causw.app.main.domain.community.post.service.util.LikePostValidator; import net.causw.app.main.domain.community.post.service.util.PostValidator; +import net.causw.app.main.domain.community.reaction.service.implementation.LikePostReader; import net.causw.app.main.domain.community.reaction.service.implementation.LikePostWriter; -import net.causw.app.main.domain.notification.notification.event.PostLikedEvent; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; +import net.causw.app.main.domain.notification.notification.service.policy.LikePostNotificationPolicy; import net.causw.app.main.domain.user.account.entity.user.User; import net.causw.app.main.domain.user.account.service.implementation.UserReader; import net.causw.app.main.domain.user.relation.service.implementation.BlockReader; @@ -26,6 +28,7 @@ public class LikePostService { private final PostReader postReader; + private final LikePostReader likePostReader; private final LikePostWriter likePostWriter; private final LikePostValidator likePostValidator; private final ApplicationEventPublisher eventPublisher; @@ -52,8 +55,10 @@ public void likePost(String userId, String postId) { likePostWriter.saveLikePost(userId, post); - // 좋아요 알림 이벤트 - eventPublisher.publishEvent(new PostLikedEvent(postId, userId)); + long likeCount = likePostReader.countByPostId(postId); + if (LikePostNotificationPolicy.isMilestone(likeCount)) { + eventPublisher.publishEvent(new PostLikeMilestoneReachedEvent(postId, userId, likeCount)); + } } /** diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievement.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievement.java new file mode 100644 index 000000000..529d8219a --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievement.java @@ -0,0 +1,90 @@ +package net.causw.app.main.domain.notification.notification.entity; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneAchievementStatus; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.shared.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "tb_post_like_milestone_achievement", uniqueConstraints = { + @UniqueConstraint(name = "uk_post_like_milestone_achievement_post_milestone", columnNames = {"post_id", + "milestone_count"}) +}, indexes = { + @Index(name = "idx_post_like_milestone_achievement_trigger_user", columnList = "trigger_user_id") +}) +public class PostLikeMilestoneAchievement extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false, updatable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "trigger_user_id", updatable = false) + private User triggerUser; + + @Column(name = "milestone_count", nullable = false, updatable = false) + private long milestoneCount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private PostLikeMilestoneAchievementStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "suppression_reason", length = 32) + private PostLikeMilestoneSuppressionReason suppressionReason; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", unique = true) + private Notification notification; + + public static PostLikeMilestoneAchievement pending(Post post, User triggerUser, long milestoneCount) { + return PostLikeMilestoneAchievement.builder() + .post(post) + .triggerUser(triggerUser) + .milestoneCount(milestoneCount) + .status(PostLikeMilestoneAchievementStatus.PENDING) + .build(); + } + + public static PostLikeMilestoneAchievement baselined(Post post, long milestoneCount) { + return PostLikeMilestoneAchievement.builder() + .post(post) + .milestoneCount(milestoneCount) + .status(PostLikeMilestoneAchievementStatus.BASELINED) + .build(); + } + + public void markNotificationCreated(Notification notification) { + this.notification = notification; + this.suppressionReason = null; + this.status = PostLikeMilestoneAchievementStatus.NOTIFICATION_CREATED; + } + + public void suppress(PostLikeMilestoneSuppressionReason suppressionReason) { + this.notification = null; + this.suppressionReason = suppressionReason; + this.status = PostLikeMilestoneAchievementStatus.SUPPRESSED; + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneAchievementStatus.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneAchievementStatus.java new file mode 100644 index 000000000..0d86fef35 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneAchievementStatus.java @@ -0,0 +1,8 @@ +package net.causw.app.main.domain.notification.notification.enums; + +public enum PostLikeMilestoneAchievementStatus { + PENDING, + BASELINED, + NOTIFICATION_CREATED, + SUPPRESSED +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneSuppressionReason.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneSuppressionReason.java new file mode 100644 index 000000000..f1191155c --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/enums/PostLikeMilestoneSuppressionReason.java @@ -0,0 +1,8 @@ +package net.causw.app.main.domain.notification.notification.enums; + +public enum PostLikeMilestoneSuppressionReason { + SELF_LIKE, + SETTING_DISABLED, + BLOCKED, + TARGET_UNAVAILABLE +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestonePushEvent.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestonePushEvent.java new file mode 100644 index 000000000..db1bac5a5 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestonePushEvent.java @@ -0,0 +1,10 @@ +package net.causw.app.main.domain.notification.notification.event; + +import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; + +public record PostLikeMilestonePushEvent( + String recipientUserId, + String pushTitle, + String pushBody, + PushNotificationData pushData) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestoneReachedEvent.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestoneReachedEvent.java new file mode 100644 index 000000000..f29c65b88 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikeMilestoneReachedEvent.java @@ -0,0 +1,4 @@ +package net.causw.app.main.domain.notification.notification.event; + +public record PostLikeMilestoneReachedEvent(String postId, String likerId, long milestoneCount) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikedEvent.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikedEvent.java deleted file mode 100644 index 259aa6f83..000000000 --- a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/event/PostLikedEvent.java +++ /dev/null @@ -1,4 +0,0 @@ -package net.causw.app.main.domain.notification.notification.event; - -public record PostLikedEvent(String postId, String likerId) { -} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/repository/PostLikeMilestoneAchievementRepository.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/repository/PostLikeMilestoneAchievementRepository.java new file mode 100644 index 000000000..b0b558721 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/repository/PostLikeMilestoneAchievementRepository.java @@ -0,0 +1,13 @@ +package net.causw.app.main.domain.notification.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; + +@Repository +public interface PostLikeMilestoneAchievementRepository + extends JpaRepository { + + boolean existsByPostIdAndMilestoneCount(String postId, long milestoneCount); +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReader.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReader.java new file mode 100644 index 000000000..e97fb248f --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReader.java @@ -0,0 +1,24 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.repository.PostLikeMilestoneAchievementRepository; +import net.causw.app.main.shared.exception.errorcode.PostLikeMilestoneAchievementErrorCode; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostLikeMilestoneAchievementReader { + + private final PostLikeMilestoneAchievementRepository achievementRepository; + + public PostLikeMilestoneAchievement findById(String achievementId) { + return achievementRepository.findById(achievementId) + .orElseThrow( + PostLikeMilestoneAchievementErrorCode.POST_LIKE_MILESTONE_ACHIEVEMENT_NOT_FOUND::toBaseException); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorder.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorder.java new file mode 100644 index 000000000..50193aea2 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorder.java @@ -0,0 +1,33 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.community.post.service.implementation.PostReader; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.service.implementation.UserReader; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostLikeMilestoneAchievementRecorder { + + private final PostReader postReader; + private final UserReader userReader; + private final PostLikeMilestoneAchievementWriter achievementWriter; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional record(PostLikeMilestoneReachedEvent event) { + Post post = postReader.findById(event.postId()); + User liker = userReader.findUserById(event.likerId()); + + return achievementWriter.savePendingIfAbsent(post, liker, event.milestoneCount()) + .map(achievement -> achievement.getId()); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriter.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriter.java new file mode 100644 index 000000000..940561043 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriter.java @@ -0,0 +1,52 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.entity.Notification; +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.notification.notification.repository.PostLikeMilestoneAchievementRepository; +import net.causw.app.main.domain.user.account.entity.user.User; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional +public class PostLikeMilestoneAchievementWriter { + + private final PostLikeMilestoneAchievementRepository achievementRepository; + + public Optional savePendingIfAbsent( + Post post, + User triggerUser, + long milestoneCount) { + if (achievementRepository.existsByPostIdAndMilestoneCount(post.getId(), milestoneCount)) { + return Optional.empty(); + } + + PostLikeMilestoneAchievement achievement = PostLikeMilestoneAchievement.pending( + post, + triggerUser, + milestoneCount); + return Optional.of(achievementRepository.saveAndFlush(achievement)); + } + + public PostLikeMilestoneAchievement suppress( + PostLikeMilestoneAchievement achievement, + PostLikeMilestoneSuppressionReason suppressionReason) { + achievement.suppress(suppressionReason); + return achievementRepository.save(achievement); + } + + public PostLikeMilestoneAchievement markNotificationCreated( + PostLikeMilestoneAchievement achievement, + Notification notification) { + achievement.markNotificationCreated(notification); + return achievementRepository.save(achievement); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessor.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessor.java new file mode 100644 index 000000000..372549f04 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessor.java @@ -0,0 +1,93 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.common.service.CommunityPermissionPolicy; +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.entity.Notification; +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.enums.NoticeType; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneAchievementStatus; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.notification.notification.enums.UserNotificationSettingKey; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestonePushEvent; +import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; +import net.causw.app.main.domain.notification.notification.service.dto.UserNotificationSettingMap; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.relation.service.implementation.BlockReader; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostLikeMilestoneNotificationProcessor { + + private final PostLikeMilestoneAchievementReader achievementReader; + private final PostLikeMilestoneAchievementWriter achievementWriter; + private final NotificationWriter notificationWriter; + private final NotificationSettingReader notificationSettingReader; + private final BlockReader blockReader; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void process(String achievementId) { + PostLikeMilestoneAchievement achievement = achievementReader.findById(achievementId); + if (achievement.getStatus() != PostLikeMilestoneAchievementStatus.PENDING) { + return; + } + + Post post = achievement.getPost(); + User liker = achievement.getTriggerUser(); + User postWriter = post == null ? null : post.getWriter(); + + if (liker == null || isTargetUnavailable(post, postWriter)) { + achievementWriter.suppress(achievement, PostLikeMilestoneSuppressionReason.TARGET_UNAVAILABLE); + return; + } + + if (liker.getId().equals(postWriter.getId())) { + achievementWriter.suppress(achievement, PostLikeMilestoneSuppressionReason.SELF_LIKE); + return; + } + + UserNotificationSettingMap settingMap = notificationSettingReader.findSettingMap(postWriter.getId()); + if (!settingMap.get(UserNotificationSettingKey.COMMUNITY_LIKE_ON_MY_POST)) { + achievementWriter.suppress(achievement, PostLikeMilestoneSuppressionReason.SETTING_DISABLED); + return; + } + + if (blockReader.existsByBlockerAndBlocked(postWriter, liker)) { + achievementWriter.suppress(achievement, PostLikeMilestoneSuppressionReason.BLOCKED); + return; + } + + long likeCount = achievement.getMilestoneCount(); + String serviceTitle = String.format("게시물이 좋아요 %d개를 달성했습니다!", likeCount); + String serviceBody = String.format("내 게시글에 좋아요가 %d개 달렸어요.", likeCount); + String pushTitle = String.format("게시물 좋아요 %d개 달성", likeCount); + PushNotificationData pushData = new PushNotificationData(NoticeType.COMMUNITY, post.getId(), + post.getBoard().getId()); + + Notification notification = notificationWriter.save( + Notification.of(postWriter, serviceTitle, serviceBody, NoticeType.COMMUNITY, post.getId(), + post.getBoard().getId())); + notificationWriter.saveLog(postWriter, notification); + achievementWriter.markNotificationCreated(achievement, notification); + + eventPublisher.publishEvent(new PostLikeMilestonePushEvent( + postWriter.getId(), + pushTitle, + serviceBody, + pushData)); + } + + private boolean isTargetUnavailable(Post post, User postWriter) { + return !CommunityPermissionPolicy.isAlive(post) + || postWriter == null + || postWriter.isInactive() + || postWriter.isDropped(); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListener.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListener.java deleted file mode 100644 index 1169c276a..000000000 --- a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListener.java +++ /dev/null @@ -1,106 +0,0 @@ -package net.causw.app.main.domain.notification.notification.service.listener; - -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import net.causw.app.main.domain.community.post.entity.Post; -import net.causw.app.main.domain.community.post.service.implementation.PostReader; -import net.causw.app.main.domain.community.reaction.service.implementation.LikePostReader; -import net.causw.app.main.domain.notification.notification.entity.Notification; -import net.causw.app.main.domain.notification.notification.enums.NoticeType; -import net.causw.app.main.domain.notification.notification.enums.UserNotificationSettingKey; -import net.causw.app.main.domain.notification.notification.event.PostLikedEvent; -import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; -import net.causw.app.main.domain.notification.notification.service.dto.UserNotificationSettingMap; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationPushSender; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationSettingReader; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationWriter; -import net.causw.app.main.domain.user.account.entity.user.User; -import net.causw.app.main.domain.user.account.service.implementation.UserReader; -import net.causw.app.main.domain.user.relation.service.implementation.BlockReader; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class LikePostNotificationListener { - - private final PostReader postReader; - private final UserReader userReader; - private final LikePostReader likePostReader; - private final NotificationWriter notificationWriter; - private final NotificationPushSender notificationPushSender; - private final NotificationSettingReader notificationSettingReader; - private final BlockReader blockReader; - - /** - * 좋아요 이벤트 처리 핸들러 - * - 게시글 작성자에게 좋아요 알림을 보내는 역할 - * - 좋아요 수가 특정 마일스톤(5, 10, 50, 100, 500, 1000의 배수)에 도달했을 때 알림을 보냄 - * - 작성자가 좋아요를 누른 경우, 또는 작성자가 좋아요 알림을 꺼놓은 경우, 또는 작성자가 좋아요 누른 유저를 차단한 경우에는 알림을 보내지 않음 - * - 알림 방향: liker -> postWriter - * @param event 게시글 좋아요 이벤트 객체 - */ - @Async("asyncExecutor") - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handle(PostLikedEvent event) { - // ID로 게시글·좋아요 누른 유저 조회 - Post post = postReader.findById(event.postId()); - User liker = userReader.findUserById(event.likerId()); - User postWriter = post.getWriter(); - - // 작성자가 좋아요를 누른 경우 알림을 보내지 않음 - if (liker.getId().equals(postWriter.getId())) { - return; - } - - // 작성자가 좋아요 알림을 꺼놓은 경우 알림을 보내지 않음 - UserNotificationSettingMap settingMap = notificationSettingReader.findSettingMap(postWriter.getId()); - if (!settingMap.get(UserNotificationSettingKey.COMMUNITY_LIKE_ON_MY_POST)) { - return; - } - - // 작성자가 좋아요 누른 유저를 차단한 경우 알림을 보내지 않음 - if (blockReader.existsByBlockerAndBlocked(postWriter, liker)) { - return; - } - - // 좋아요 수가 특정 마일스톤에 도달했는지 확인 - long likeCount = likePostReader.countByPostId(post.getId()); - if (!isLikeCountMilestone(likeCount)) { - return; - } - - // 게시글 좋아요 알림 생성 - String serviceTitle = String.format("게시물이 좋아요 %d개를 달성했습니다!", likeCount); - String serviceBody = String.format("내 게시글에 좋아요가 %d개 달렸어요.", likeCount); - String pushTitle = String.format("게시물 좋아요 %d개 달성", likeCount); - PushNotificationData pushData = new PushNotificationData(NoticeType.COMMUNITY, post.getId(), - post.getBoard().getId()); - - // 알림 발송자를 게시글 작성자로 설정하여 알림 생성 - Notification notification = notificationWriter.save( - Notification.of(postWriter, serviceTitle, serviceBody, NoticeType.COMMUNITY, post.getId(), - post.getBoard().getId())); - - // 작성자에게 푸시 알림 발송 및 알림 로그 저장 - notificationPushSender.sendToUser(postWriter, pushTitle, serviceBody, pushData); - notificationWriter.saveLog(postWriter, notification); - } - - /** - * - * @param count 좋아요 수가 특정 마일스톤(5, 10, 50, 100, 500, 1000의 배수)에 도달했는지 확인하는 메서드 - * @return 좋아요 수가 마일스톤에 도달했으면 true, 그렇지 않으면 false - */ - private boolean isLikeCountMilestone(long count) { - if (count == 5 || count == 10 || count == 50 || count == 100 || count == 500) - return true; - return count >= 1000 && count % 1000 == 0; - } -} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListener.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListener.java new file mode 100644 index 000000000..82934b665 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListener.java @@ -0,0 +1,35 @@ +package net.causw.app.main.domain.notification.notification.service.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestonePushEvent; +import net.causw.app.main.domain.notification.notification.service.implementation.NotificationPushSender; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.service.implementation.UserReader; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostLikeMilestonePushListener { + + private final UserReader userReader; + private final NotificationPushSender notificationPushSender; + + @Async("asyncExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(PostLikeMilestonePushEvent event) { + User recipient = userReader.findUserById(event.recipientUserId()); + notificationPushSender.sendToUser( + recipient, + event.pushTitle(), + event.pushBody(), + event.pushData()); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListener.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListener.java new file mode 100644 index 000000000..6d38755f0 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListener.java @@ -0,0 +1,27 @@ +package net.causw.app.main.domain.notification.notification.service.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; +import net.causw.app.main.domain.notification.notification.service.implementation.PostLikeMilestoneAchievementRecorder; +import net.causw.app.main.domain.notification.notification.service.implementation.PostLikeMilestoneNotificationProcessor; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PostLikeMilestoneReachedListener { + + private final PostLikeMilestoneAchievementRecorder achievementRecorder; + private final PostLikeMilestoneNotificationProcessor notificationProcessor; + + @Async("asyncExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(PostLikeMilestoneReachedEvent event) { + achievementRecorder.record(event) + .ifPresent(notificationProcessor::process); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicy.java b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicy.java new file mode 100644 index 000000000..efc5aea78 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicy.java @@ -0,0 +1,19 @@ +package net.causw.app.main.domain.notification.notification.service.policy; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LikePostNotificationPolicy { + + public static boolean isMilestone(long likeCount) { + if ((likeCount >= 1 && likeCount <= 5) + || likeCount == 10 + || likeCount == 50 + || likeCount == 100 + || likeCount == 500) { + return true; + } + return likeCount >= 1000 && likeCount % 1000 == 0; + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/PostLikeMilestoneAchievementErrorCode.java b/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/PostLikeMilestoneAchievementErrorCode.java new file mode 100644 index 000000000..6d68ca2da --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/PostLikeMilestoneAchievementErrorCode.java @@ -0,0 +1,36 @@ +package net.causw.app.main.shared.exception.errorcode; + +import org.springframework.http.HttpStatus; + +import net.causw.app.main.shared.exception.BaseResponseCode; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum PostLikeMilestoneAchievementErrorCode implements BaseResponseCode { + + POST_LIKE_MILESTONE_ACHIEVEMENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "POST_LIKE_MILESTONE_ACHIEVEMENT_404_001", + "게시글 좋아요 마일스톤 이력을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getStatus() { + return status; + } +} diff --git a/app-main/src/main/resources/db/migration/V20260801133019__CreatePostLikeMilestoneAchievementTable.sql b/app-main/src/main/resources/db/migration/V20260801133019__CreatePostLikeMilestoneAchievementTable.sql new file mode 100644 index 000000000..e9615a581 --- /dev/null +++ b/app-main/src/main/resources/db/migration/V20260801133019__CreatePostLikeMilestoneAchievementTable.sql @@ -0,0 +1,84 @@ +CREATE TABLE tb_post_like_milestone_achievement +( + id VARCHAR(255) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + post_id VARCHAR(255) NOT NULL, + trigger_user_id VARCHAR(255) NULL, + milestone_count BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + suppression_reason VARCHAR(32) NULL, + notification_id VARCHAR(255) NULL, + CONSTRAINT pk_post_like_milestone_achievement PRIMARY KEY (id), + CONSTRAINT uk_post_like_milestone_achievement_post_milestone UNIQUE (post_id, milestone_count), + CONSTRAINT uk_post_like_milestone_achievement_notification UNIQUE (notification_id), + INDEX idx_post_like_milestone_achievement_trigger_user (trigger_user_id), + CONSTRAINT fk_post_like_milestone_achievement_post + FOREIGN KEY (post_id) REFERENCES tb_post (id), + CONSTRAINT fk_post_like_milestone_achievement_trigger_user + FOREIGN KEY (trigger_user_id) REFERENCES tb_user (id), + CONSTRAINT fk_post_like_milestone_achievement_notification + FOREIGN KEY (notification_id) REFERENCES tb_notification (id), + CONSTRAINT chk_post_like_milestone_achievement_count CHECK (milestone_count > 0) +); + +-- 기존 게시글은 현재 좋아요 수 이하의 마일스톤을 이미 소비한 것으로 처리한다. +-- 알림 및 알림 로그는 생성하지 않는다. +INSERT INTO tb_post_like_milestone_achievement ( + id, + created_at, + updated_at, + post_id, + trigger_user_id, + milestone_count, + status, + suppression_reason, + notification_id +) +WITH RECURSIVE +post_like_counts AS ( + SELECT post_id, COUNT(*) AS like_count + FROM tb_like_post + WHERE post_id IS NOT NULL + GROUP BY post_id +), +fixed_milestones AS ( + SELECT 5 AS milestone_count + UNION ALL SELECT 10 + UNION ALL SELECT 50 + UNION ALL SELECT 100 + UNION ALL SELECT 500 +), +thousand_milestones (post_id, like_count, milestone_count) AS ( + SELECT post_id, like_count, 1000 + FROM post_like_counts + WHERE like_count >= 1000 + + UNION ALL + + SELECT post_id, like_count, milestone_count + 1000 + FROM thousand_milestones + WHERE milestone_count + 1000 <= like_count +), +reached_milestones AS ( + SELECT post_like_counts.post_id, fixed_milestones.milestone_count + FROM post_like_counts + INNER JOIN fixed_milestones + ON fixed_milestones.milestone_count <= post_like_counts.like_count + + UNION ALL + + SELECT post_id, milestone_count + FROM thousand_milestones +) +SELECT + UUID(), + NOW(6), + NOW(6), + post_id, + NULL, + milestone_count, + 'BASELINED', + NULL, + NULL +FROM reached_milestones; diff --git a/app-main/src/main/resources/db/migration/V20260801170000__BaselineEarlyPostLikeMilestones.sql b/app-main/src/main/resources/db/migration/V20260801170000__BaselineEarlyPostLikeMilestones.sql new file mode 100644 index 000000000..95114f317 --- /dev/null +++ b/app-main/src/main/resources/db/migration/V20260801170000__BaselineEarlyPostLikeMilestones.sql @@ -0,0 +1,39 @@ +-- 정책 변경 전에 좋아요 1~4개를 이미 달성한 기존 게시글은 해당 마일스톤을 소비한 것으로 처리한다. +-- 이후 좋아요 취소와 재등록으로 같은 마일스톤에 재도달해도 알림 및 알림 로그를 생성하지 않는다. +INSERT INTO tb_post_like_milestone_achievement ( + id, + created_at, + updated_at, + post_id, + trigger_user_id, + milestone_count, + status, + suppression_reason, + notification_id +) +WITH +post_like_counts AS ( + SELECT post_id, COUNT(*) AS like_count + FROM tb_like_post + WHERE post_id IS NOT NULL + GROUP BY post_id +), +early_milestones AS ( + SELECT 1 AS milestone_count + UNION ALL SELECT 2 + UNION ALL SELECT 3 + UNION ALL SELECT 4 +) +SELECT + UUID(), + NOW(6), + NOW(6), + post_like_counts.post_id, + NULL, + early_milestones.milestone_count, + 'BASELINED', + NULL, + NULL +FROM post_like_counts +INNER JOIN early_milestones + ON early_milestones.milestone_count <= post_like_counts.like_count; diff --git a/app-main/src/test/java/net/causw/app/main/SchemaValidationTest.java b/app-main/src/test/java/net/causw/app/main/SchemaValidationTest.java index 198bbc149..30e1af839 100644 --- a/app-main/src/test/java/net/causw/app/main/SchemaValidationTest.java +++ b/app-main/src/test/java/net/causw/app/main/SchemaValidationTest.java @@ -25,7 +25,7 @@ @ContextConfiguration(classes = SchemaValidationTest.Config.class) public class SchemaValidationTest { - private static final String targetPackages = "net.causw.app.main.domain.model.entity"; + private static final String ENTITY_BASE_PACKAGE = "net.causw.app.main.domain"; @Test void contextLoads() { @@ -33,7 +33,7 @@ void contextLoads() { } @TestConfiguration - @EntityScan(basePackages = targetPackages) + @EntityScan(basePackages = ENTITY_BASE_PACKAGE) static class Config { @Bean @@ -52,7 +52,7 @@ public LocalContainerEntityManagerFactoryBean entityManagerFactory( LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean(); emf.setDataSource(dataSource); - emf.setPackagesToScan(targetPackages); + emf.setPackagesToScan(ENTITY_BASE_PACKAGE); emf.setJpaVendorAdapter(jpaVendorAdapter); emf.setJpaProperties(jpaProperties()); @@ -81,4 +81,4 @@ public PlatformTransactionManager transactionManager(EntityManagerFactory emf) { return new JpaTransactionManager(emf); } } -} \ No newline at end of file +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/community/post/service/LikePostServiceTest.java b/app-main/src/test/java/net/causw/app/main/domain/community/post/service/LikePostServiceTest.java index 6c1d7716a..4f77e2e73 100644 --- a/app-main/src/test/java/net/causw/app/main/domain/community/post/service/LikePostServiceTest.java +++ b/app-main/src/test/java/net/causw/app/main/domain/community/post/service/LikePostServiceTest.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -25,8 +27,9 @@ import net.causw.app.main.domain.community.post.entity.Post; import net.causw.app.main.domain.community.post.service.implementation.PostReader; import net.causw.app.main.domain.community.post.service.util.LikePostValidator; +import net.causw.app.main.domain.community.reaction.service.implementation.LikePostReader; import net.causw.app.main.domain.community.reaction.service.implementation.LikePostWriter; -import net.causw.app.main.domain.notification.notification.event.PostLikedEvent; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; import net.causw.app.main.domain.user.academic.enums.userAcademicRecord.AcademicStatus; import net.causw.app.main.domain.user.account.entity.user.User; import net.causw.app.main.domain.user.account.service.implementation.UserReader; @@ -43,6 +46,9 @@ public class LikePostServiceTest { @Mock PostReader postReader; + @Mock + LikePostReader likePostReader; + @Mock LikePostWriter likePostWriter; @@ -84,16 +90,35 @@ void setUp() { given(boardConfigReader.getAdminIdsByBoardId("board-id")).willReturn(List.of("admin-id")); } - @DisplayName("게시글 좋아요 성공") - @Test - void likePost_shouldSucceed() { + @DisplayName("좋아요 수가 1~5이면 매번 알림 전용 이벤트를 발행함") + @ParameterizedTest(name = "좋아요 {0}개") + @ValueSource(longs = {1, 2, 3, 4, 5}) + void likePost_shouldPublishEvent_whenLikeCountIsBetweenOneAndFive(long likeCount) { + // given + given(likePostReader.countByPostId("post-id")).willReturn(likeCount); + // when likePostService.likePost("user-id", "post-id"); // then verify(likePostValidator, times(1)).validateForLike("user-id", "post-id"); verify(likePostWriter, times(1)).saveLikePost("user-id", post); - verify(eventPublisher, times(1)).publishEvent(any(PostLikedEvent.class)); + verify(eventPublisher) + .publishEvent(new PostLikeMilestoneReachedEvent("post-id", "user-id", likeCount)); + } + + @DisplayName("마일스톤이 아닌 좋아요 수에서는 알림 전용 이벤트를 발행하지 않음") + @Test + void likePost_shouldNotPublishEvent_whenLikeCountIsNotMilestone() { + // given + given(likePostReader.countByPostId("post-id")).willReturn(6L); + + // when + likePostService.likePost("user-id", "post-id"); + + // then + verify(likePostWriter).saveLikePost("user-id", post); + verify(eventPublisher, never()).publishEvent(any()); } @DisplayName("게시판 읽기 권한이 없으면 게시글 좋아요 실패") diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievementTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievementTest.java new file mode 100644 index 000000000..7d2560d88 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/entity/PostLikeMilestoneAchievementTest.java @@ -0,0 +1,88 @@ +package net.causw.app.main.domain.notification.notification.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneAchievementStatus; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.user.account.entity.user.User; + +class PostLikeMilestoneAchievementTest { + + @Nested + @DisplayName("생성") + class CreationTest { + + @Test + @DisplayName("신규 마일스톤은 트리거 사용자와 함께 대기 상태로 생성한다") + void givenPostAndTriggerUser_whenCreatePending_thenSetPendingStatus() { + Post post = mock(Post.class); + User triggerUser = mock(User.class); + + PostLikeMilestoneAchievement achievement = PostLikeMilestoneAchievement.pending(post, triggerUser, 100L); + + assertThat(achievement.getPost()).isSameAs(post); + assertThat(achievement.getTriggerUser()).isSameAs(triggerUser); + assertThat(achievement.getMilestoneCount()).isEqualTo(100L); + assertThat(achievement.getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.PENDING); + assertThat(achievement.getSuppressionReason()).isNull(); + assertThat(achievement.getNotification()).isNull(); + } + + @Test + @DisplayName("기존 게시글의 도달 마일스톤은 트리거 사용자 없이 기준선 상태로 생성한다") + void givenExistingPost_whenCreateBaselined_thenSetBaselinedStatus() { + Post post = mock(Post.class); + + PostLikeMilestoneAchievement achievement = PostLikeMilestoneAchievement.baselined(post, 500L); + + assertThat(achievement.getPost()).isSameAs(post); + assertThat(achievement.getTriggerUser()).isNull(); + assertThat(achievement.getMilestoneCount()).isEqualTo(500L); + assertThat(achievement.getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.BASELINED); + assertThat(achievement.getSuppressionReason()).isNull(); + assertThat(achievement.getNotification()).isNull(); + } + } + + @Nested + @DisplayName("처리 결과 기록") + class ResultRecordingTest { + + @Test + @DisplayName("인앱 알림 생성 결과를 알림과 함께 기록한다") + void givenPendingAchievement_whenMarkNotificationCreated_thenRecordNotification() { + PostLikeMilestoneAchievement achievement = pendingAchievement(); + Notification notification = mock(Notification.class); + + achievement.markNotificationCreated(notification); + + assertThat(achievement.getStatus()) + .isEqualTo(PostLikeMilestoneAchievementStatus.NOTIFICATION_CREATED); + assertThat(achievement.getNotification()).isSameAs(notification); + assertThat(achievement.getSuppressionReason()).isNull(); + } + + @Test + @DisplayName("알림 억제 결과를 사유와 함께 기록한다") + void givenPendingAchievement_whenSuppress_thenRecordReason() { + PostLikeMilestoneAchievement achievement = pendingAchievement(); + + achievement.suppress(PostLikeMilestoneSuppressionReason.SETTING_DISABLED); + + assertThat(achievement.getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.SUPPRESSED); + assertThat(achievement.getSuppressionReason()) + .isEqualTo(PostLikeMilestoneSuppressionReason.SETTING_DISABLED); + assertThat(achievement.getNotification()).isNull(); + } + } + + private PostLikeMilestoneAchievement pendingAchievement() { + return PostLikeMilestoneAchievement.pending(mock(Post.class), mock(User.class), 100L); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReaderTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReaderTest.java new file mode 100644 index 000000000..1a3a982b3 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementReaderTest.java @@ -0,0 +1,48 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.repository.PostLikeMilestoneAchievementRepository; +import net.causw.app.main.shared.exception.BaseRunTimeV2Exception; +import net.causw.app.main.shared.exception.errorcode.PostLikeMilestoneAchievementErrorCode; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestoneAchievementReaderTest { + + @InjectMocks + private PostLikeMilestoneAchievementReader reader; + + @Mock + private PostLikeMilestoneAchievementRepository achievementRepository; + + @Test + void givenExistingAchievement_whenFindById_thenReturnAchievement() { + PostLikeMilestoneAchievement achievement = mock(PostLikeMilestoneAchievement.class); + given(achievementRepository.findById("achievementId")).willReturn(Optional.of(achievement)); + + PostLikeMilestoneAchievement result = reader.findById("achievementId"); + + assertThat(result).isSameAs(achievement); + } + + @Test + void givenMissingAchievement_whenFindById_thenThrowDomainException() { + given(achievementRepository.findById("achievementId")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> reader.findById("achievementId")) + .isInstanceOfSatisfying(BaseRunTimeV2Exception.class, exception -> assertThat(exception.getErrorCode()) + .isEqualTo(PostLikeMilestoneAchievementErrorCode.POST_LIKE_MILESTONE_ACHIEVEMENT_NOT_FOUND)); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorderTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorderTest.java new file mode 100644 index 000000000..3e0ef55fc --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementRecorderTest.java @@ -0,0 +1,83 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.community.post.service.implementation.PostReader; +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.service.implementation.UserReader; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestoneAchievementRecorderTest { + + @InjectMocks + private PostLikeMilestoneAchievementRecorder recorder; + + @Mock + private PostReader postReader; + @Mock + private UserReader userReader; + @Mock + private PostLikeMilestoneAchievementWriter achievementWriter; + + @Test + @DisplayName("신규 마일스톤 이력을 저장하고 이력 ID를 반환한다") + void givenNewMilestone_whenRecord_thenReturnAchievementId() { + Post post = mock(Post.class); + User liker = mock(User.class); + PostLikeMilestoneAchievement achievement = mock(PostLikeMilestoneAchievement.class); + given(achievement.getId()).willReturn("achievementId"); + given(postReader.findById("postId")).willReturn(post); + given(userReader.findUserById("likerId")).willReturn(liker); + given(achievementWriter.savePendingIfAbsent(post, liker, 100L)) + .willReturn(Optional.of(achievement)); + + Optional result = recorder.record( + new PostLikeMilestoneReachedEvent("postId", "likerId", 100L)); + + assertThat(result).contains("achievementId"); + } + + @Test + @DisplayName("이미 소비한 마일스톤이면 빈 결과를 반환한다") + void givenConsumedMilestone_whenRecord_thenReturnEmpty() { + Post post = mock(Post.class); + User liker = mock(User.class); + given(postReader.findById("postId")).willReturn(post); + given(userReader.findUserById("likerId")).willReturn(liker); + given(achievementWriter.savePendingIfAbsent(post, liker, 100L)).willReturn(Optional.empty()); + + Optional result = recorder.record( + new PostLikeMilestoneReachedEvent("postId", "likerId", 100L)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("이력 기록은 독립 트랜잭션에서 실행된다") + void record_shouldUseRequiresNewTransaction() throws NoSuchMethodException { + Method recordMethod = PostLikeMilestoneAchievementRecorder.class + .getMethod("record", PostLikeMilestoneReachedEvent.class); + + Transactional annotation = recordMethod.getAnnotation(Transactional.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.propagation()).isEqualTo(Propagation.REQUIRES_NEW); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriterTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriterTest.java new file mode 100644 index 000000000..8c49c7dca --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneAchievementWriterTest.java @@ -0,0 +1,107 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.mock; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.entity.Notification; +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneAchievementStatus; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.notification.notification.repository.PostLikeMilestoneAchievementRepository; +import net.causw.app.main.domain.user.account.entity.user.User; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestoneAchievementWriterTest { + + @InjectMocks + private PostLikeMilestoneAchievementWriter writer; + + @Mock + private PostLikeMilestoneAchievementRepository achievementRepository; + + @Test + @DisplayName("게시글의 마일스톤 이력이 없으면 PENDING 이력을 저장한다") + void givenUnconsumedMilestone_whenSavePendingIfAbsent_thenSavePendingAchievement() { + Post post = postWithId("postId"); + User triggerUser = mock(User.class); + given(achievementRepository.existsByPostIdAndMilestoneCount("postId", 100L)).willReturn(false); + given(achievementRepository.saveAndFlush(any(PostLikeMilestoneAchievement.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + Optional result = writer.savePendingIfAbsent(post, triggerUser, 100L); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().getPost()).isSameAs(post); + assertThat(result.orElseThrow().getTriggerUser()).isSameAs(triggerUser); + assertThat(result.orElseThrow().getMilestoneCount()).isEqualTo(100L); + assertThat(result.orElseThrow().getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.PENDING); + } + + @Test + @DisplayName("이미 소비한 마일스톤이면 새 이력을 저장하지 않는다") + void givenConsumedMilestone_whenSavePendingIfAbsent_thenReturnEmpty() { + Post post = postWithId("postId"); + User triggerUser = mock(User.class); + given(achievementRepository.existsByPostIdAndMilestoneCount("postId", 100L)).willReturn(true); + + Optional result = writer.savePendingIfAbsent(post, triggerUser, 100L); + + assertThat(result).isEmpty(); + verify(achievementRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("억제 사유와 SUPPRESSED 상태를 저장한다") + void givenPendingAchievement_whenSuppress_thenSaveSuppressedResult() { + PostLikeMilestoneAchievement achievement = pendingAchievement(); + given(achievementRepository.save(achievement)).willReturn(achievement); + + PostLikeMilestoneAchievement result = writer.suppress( + achievement, + PostLikeMilestoneSuppressionReason.BLOCKED); + + assertThat(result.getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.SUPPRESSED); + assertThat(result.getSuppressionReason()).isEqualTo(PostLikeMilestoneSuppressionReason.BLOCKED); + assertThat(result.getNotification()).isNull(); + verify(achievementRepository).save(achievement); + } + + @Test + @DisplayName("생성된 알림과 NOTIFICATION_CREATED 상태를 저장한다") + void givenPendingAchievement_whenMarkNotificationCreated_thenSaveNotificationResult() { + PostLikeMilestoneAchievement achievement = pendingAchievement(); + Notification notification = mock(Notification.class); + given(achievementRepository.save(achievement)).willReturn(achievement); + + PostLikeMilestoneAchievement result = writer.markNotificationCreated(achievement, notification); + + assertThat(result.getStatus()).isEqualTo(PostLikeMilestoneAchievementStatus.NOTIFICATION_CREATED); + assertThat(result.getNotification()).isSameAs(notification); + assertThat(result.getSuppressionReason()).isNull(); + verify(achievementRepository).save(achievement); + } + + private Post postWithId(String postId) { + Post post = mock(Post.class); + given(post.getId()).willReturn(postId); + return post; + } + + private PostLikeMilestoneAchievement pendingAchievement() { + return PostLikeMilestoneAchievement.pending(mock(Post.class), mock(User.class), 100L); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessorTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessorTest.java new file mode 100644 index 000000000..7ec71adc1 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/implementation/PostLikeMilestoneNotificationProcessorTest.java @@ -0,0 +1,300 @@ +package net.causw.app.main.domain.notification.notification.service.implementation; + +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.BDDMockito.never; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import net.causw.app.main.domain.community.board.entity.Board; +import net.causw.app.main.domain.community.post.entity.Post; +import net.causw.app.main.domain.notification.notification.entity.Notification; +import net.causw.app.main.domain.notification.notification.entity.PostLikeMilestoneAchievement; +import net.causw.app.main.domain.notification.notification.enums.NoticeType; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneAchievementStatus; +import net.causw.app.main.domain.notification.notification.enums.PostLikeMilestoneSuppressionReason; +import net.causw.app.main.domain.notification.notification.enums.UserNotificationSettingKey; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestonePushEvent; +import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; +import net.causw.app.main.domain.notification.notification.service.dto.UserNotificationSettingMap; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.relation.service.implementation.BlockReader; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestoneNotificationProcessorTest { + + @InjectMocks + private PostLikeMilestoneNotificationProcessor processor; + + @Mock + private PostLikeMilestoneAchievementReader achievementReader; + @Mock + private PostLikeMilestoneAchievementWriter achievementWriter; + @Mock + private NotificationWriter notificationWriter; + @Mock + private NotificationSettingReader notificationSettingReader; + @Mock + private BlockReader blockReader; + @Mock + private ApplicationEventPublisher eventPublisher; + + @Nested + @DisplayName("게시글 좋아요 마일스톤 알림 처리") + class ProcessTest { + + @ParameterizedTest(name = "기록된 마일스톤 {0}개 처리") + @ValueSource(longs = {5, 10, 50, 100, 500, 1000, 2000, 3000}) + @DisplayName("알림·로그·이력 결과를 저장하고 커밋 후 푸시 이벤트를 예약한다") + void givenValidPendingAchievement_whenProcess_thenPersistAndPublishPushEvent(long milestoneCount) { + User postWriter = userWithId("postWriterId"); + User liker = userWithId("likerId"); + Post post = alivePostWithWriter(postWriter); + given(post.getId()).willReturn("postId"); + Board board = post.getBoard(); + given(board.getId()).willReturn("boardId"); + + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievement.getMilestoneCount()).willReturn(milestoneCount); + given(achievementReader.findById("achievementId")).willReturn(achievement); + given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); + given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(false); + Notification notification = mock(Notification.class); + given(notificationWriter.save(any(Notification.class))).willReturn(notification); + + processor.process("achievementId"); + + PushNotificationData expectedData = new PushNotificationData( + NoticeType.COMMUNITY, + "postId", + "boardId"); + PostLikeMilestonePushEvent expectedEvent = new PostLikeMilestonePushEvent( + "postWriterId", + String.format("게시물 좋아요 %d개 달성", milestoneCount), + String.format("내 게시글에 좋아요가 %d개 달렸어요.", milestoneCount), + expectedData); + + InOrder inOrder = inOrder(notificationWriter, achievementWriter, eventPublisher); + inOrder.verify(notificationWriter).save(any(Notification.class)); + inOrder.verify(notificationWriter).saveLog(postWriter, notification); + inOrder.verify(achievementWriter).markNotificationCreated(achievement, notification); + inOrder.verify(eventPublisher).publishEvent(expectedEvent); + verify(achievementWriter, never()).suppress(any(), any()); + } + + @Test + @DisplayName("알림 로그 저장이 실패하면 푸시 이벤트를 예약하지 않는다") + void givenNotificationLogFailure_whenProcess_thenDoNotPublishPushEvent() { + User postWriter = userWithId("postWriterId"); + User liker = userWithId("likerId"); + Post post = alivePostWithWriter(postWriter); + given(post.getId()).willReturn("postId"); + Board board = post.getBoard(); + given(board.getId()).willReturn("boardId"); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievement.getMilestoneCount()).willReturn(5L); + given(achievementReader.findById("achievementId")).willReturn(achievement); + given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); + given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(false); + Notification notification = mock(Notification.class); + given(notificationWriter.save(any(Notification.class))).willReturn(notification); + willThrow(new RuntimeException("notification log save failed")) + .given(notificationWriter).saveLog(postWriter, notification); + + assertThatThrownBy(() -> processor.process("achievementId")) + .isInstanceOf(RuntimeException.class) + .hasMessage("notification log save failed"); + + verify(achievementWriter, never()).markNotificationCreated(any(), any()); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("이미 처리된 이력은 다시 처리하지 않는다") + void givenCompletedAchievement_whenProcess_thenSkip() { + PostLikeMilestoneAchievement achievement = mock(PostLikeMilestoneAchievement.class); + given(achievement.getStatus()).willReturn(PostLikeMilestoneAchievementStatus.NOTIFICATION_CREATED); + given(achievementReader.findById("achievementId")).willReturn(achievement); + + processor.process("achievementId"); + + verifyNoInteractions(achievementWriter, notificationWriter, notificationSettingReader, blockReader, + eventPublisher); + } + + @Test + @DisplayName("삭제된 게시글은 TARGET_UNAVAILABLE로 소비한다") + void givenDeletedPost_whenProcess_thenSuppressAsTargetUnavailable() { + User postWriter = mock(User.class); + User liker = mock(User.class); + Post post = mock(Post.class); + given(post.getWriter()).willReturn(postWriter); + given(post.getIsDeleted()).willReturn(true); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievementReader.findById("achievementId")).willReturn(achievement); + + processor.process("achievementId"); + + verify(achievementWriter).suppress( + achievement, + PostLikeMilestoneSuppressionReason.TARGET_UNAVAILABLE); + verifyNoInteractions(notificationWriter, notificationSettingReader, blockReader, eventPublisher); + } + + @Test + @DisplayName("좋아요 사용자가 없으면 TARGET_UNAVAILABLE로 소비한다") + void givenMissingLiker_whenProcess_thenSuppressAsTargetUnavailable() { + User postWriter = mock(User.class); + Post post = mock(Post.class); + given(post.getWriter()).willReturn(postWriter); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, null); + given(achievementReader.findById("achievementId")).willReturn(achievement); + + processor.process("achievementId"); + + verify(achievementWriter).suppress( + achievement, + PostLikeMilestoneSuppressionReason.TARGET_UNAVAILABLE); + verifyNoInteractions(notificationWriter, notificationSettingReader, blockReader, eventPublisher); + } + + @ParameterizedTest(name = "작성자 상태: {0}") + @ValueSource(strings = {"inactive", "dropped"}) + @DisplayName("탈퇴 또는 추방된 작성자는 TARGET_UNAVAILABLE로 소비한다") + void givenUnavailableWriter_whenProcess_thenSuppressAsTargetUnavailable(String writerState) { + User postWriter = mock(User.class); + if ("inactive".equals(writerState)) { + given(postWriter.isInactive()).willReturn(true); + } else { + given(postWriter.isDropped()).willReturn(true); + } + User liker = mock(User.class); + Post post = alivePostWithWriter(postWriter); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievementReader.findById("achievementId")).willReturn(achievement); + + processor.process("achievementId"); + + verify(achievementWriter).suppress( + achievement, + PostLikeMilestoneSuppressionReason.TARGET_UNAVAILABLE); + verifyNoInteractions(notificationWriter, notificationSettingReader, blockReader, eventPublisher); + } + + @Test + @DisplayName("작성자의 본인 좋아요는 SELF_LIKE로 소비한다") + void givenSelfLike_whenProcess_thenSuppressAsSelfLike() { + User postWriter = userWithId("userId"); + Post post = alivePostWithWriter(postWriter); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, postWriter); + given(achievementReader.findById("achievementId")).willReturn(achievement); + + processor.process("achievementId"); + + verify(achievementWriter).suppress(achievement, PostLikeMilestoneSuppressionReason.SELF_LIKE); + verifyNoInteractions(notificationWriter, notificationSettingReader, blockReader, eventPublisher); + } + + @Test + @DisplayName("좋아요 알림 설정 OFF는 SETTING_DISABLED로 소비한다") + void givenNotificationDisabled_whenProcess_thenSuppressAsSettingDisabled() { + User postWriter = userWithId("postWriterId"); + User liker = userWithId("likerId"); + Post post = alivePostWithWriter(postWriter); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievementReader.findById("achievementId")).willReturn(achievement); + given(notificationSettingReader.findSettingMap("postWriterId")) + .willReturn(settingMapWith(UserNotificationSettingKey.COMMUNITY_LIKE_ON_MY_POST, false)); + + processor.process("achievementId"); + + verify(achievementWriter).suppress( + achievement, + PostLikeMilestoneSuppressionReason.SETTING_DISABLED); + verifyNoInteractions(notificationWriter, blockReader, eventPublisher); + } + + @Test + @DisplayName("작성자가 좋아요 사용자를 차단한 경우 BLOCKED로 소비한다") + void givenBlockedLiker_whenProcess_thenSuppressAsBlocked() { + User postWriter = userWithId("postWriterId"); + User liker = userWithId("likerId"); + Post post = alivePostWithWriter(postWriter); + PostLikeMilestoneAchievement achievement = pendingAchievement(post, liker); + given(achievementReader.findById("achievementId")).willReturn(achievement); + given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); + given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(true); + + processor.process("achievementId"); + + verify(achievementWriter).suppress(achievement, PostLikeMilestoneSuppressionReason.BLOCKED); + verifyNoInteractions(notificationWriter, eventPublisher); + } + } + + @Test + @DisplayName("알림 처리는 이력 기록과 분리된 독립 트랜잭션에서 실행된다") + void process_shouldUseRequiresNewTransaction() throws NoSuchMethodException { + Method processMethod = PostLikeMilestoneNotificationProcessor.class + .getMethod("process", String.class); + + Transactional annotation = processMethod.getAnnotation(Transactional.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.propagation()).isEqualTo(Propagation.REQUIRES_NEW); + } + + private User userWithId(String id) { + User user = mock(User.class); + given(user.getId()).willReturn(id); + return user; + } + + private Post alivePostWithWriter(User writer) { + Board board = mock(Board.class); + given(board.getIsDeleted()).willReturn(false); + Post post = mock(Post.class); + given(post.getIsDeleted()).willReturn(false); + given(post.getBoard()).willReturn(board); + given(post.getWriter()).willReturn(writer); + return post; + } + + private PostLikeMilestoneAchievement pendingAchievement(Post post, User triggerUser) { + PostLikeMilestoneAchievement achievement = mock(PostLikeMilestoneAchievement.class); + given(achievement.getStatus()).willReturn(PostLikeMilestoneAchievementStatus.PENDING); + given(achievement.getPost()).willReturn(post); + given(achievement.getTriggerUser()).willReturn(triggerUser); + return achievement; + } + + private UserNotificationSettingMap settingMapAllOn() { + return UserNotificationSettingMap.ofFull(Map.of()); + } + + private UserNotificationSettingMap settingMapWith(UserNotificationSettingKey key, boolean value) { + return UserNotificationSettingMap.ofFull(Map.of(key, value)); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListenerTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListenerTest.java deleted file mode 100644 index 72dacd9ea..000000000 --- a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/LikePostNotificationListenerTest.java +++ /dev/null @@ -1,203 +0,0 @@ -package net.causw.app.main.domain.notification.notification.service.listener; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.never; -import static org.mockito.BDDMockito.verify; -import static org.mockito.Mockito.mock; - -import java.util.Map; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import net.causw.app.main.domain.community.board.entity.Board; -import net.causw.app.main.domain.community.post.entity.Post; -import net.causw.app.main.domain.community.post.service.implementation.PostReader; -import net.causw.app.main.domain.community.reaction.service.implementation.LikePostReader; -import net.causw.app.main.domain.notification.notification.entity.Notification; -import net.causw.app.main.domain.notification.notification.enums.NoticeType; -import net.causw.app.main.domain.notification.notification.enums.UserNotificationSettingKey; -import net.causw.app.main.domain.notification.notification.event.PostLikedEvent; -import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; -import net.causw.app.main.domain.notification.notification.service.dto.UserNotificationSettingMap; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationPushSender; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationSettingReader; -import net.causw.app.main.domain.notification.notification.service.implementation.NotificationWriter; -import net.causw.app.main.domain.user.account.entity.user.User; -import net.causw.app.main.domain.user.account.service.implementation.UserReader; -import net.causw.app.main.domain.user.relation.service.implementation.BlockReader; - -@ExtendWith(MockitoExtension.class) -class LikePostNotificationListenerTest { - - @InjectMocks - private LikePostNotificationListener handler; - - @Mock - private PostReader postReader; - @Mock - private UserReader userReader; - @Mock - private LikePostReader likePostReader; - @Mock - private NotificationWriter notificationWriter; - @Mock - private NotificationPushSender notificationPushSender; - @Mock - private NotificationSettingReader notificationSettingReader; - @Mock - private BlockReader blockReader; - - @Nested - @DisplayName("게시글 좋아요 알림 (handle)") - class HandleTest { - - @ParameterizedTest(name = "좋아요 수 {0}개 달성 시 알림 발송") - @ValueSource(longs = {5, 10, 50, 100, 500, 1000, 2000, 3000}) - @DisplayName("성공: 마일스톤 도달 시 알림 저장 및 푸시 발송") - void givenMilestoneCount_whenHandle_thenSendNotification(long likeCount) { - // given - User postWriter = userWithId("postWriterId"); - User liker = userWithId("likerId"); - Post post = postWithWriter(postWriter); - // 성공 경로에서만 사용되는 stub - given(post.getId()).willReturn("postId"); - Board board = mock(Board.class); - given(board.getId()).willReturn("boardId"); - given(post.getBoard()).willReturn(board); - - given(postReader.findById("postId")).willReturn(post); - given(userReader.findUserById("likerId")).willReturn(liker); - given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); - given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(false); - given(likePostReader.countByPostId("postId")).willReturn(likeCount); - given(notificationWriter.save(any())).willReturn(mock(Notification.class)); - - // when - handler.handle(new PostLikedEvent("postId", "likerId")); - - // then - verify(notificationWriter).save(any()); - - PushNotificationData expectedData = new PushNotificationData( - NoticeType.COMMUNITY, - "postId", - "boardId"); - verify(notificationPushSender).sendToUser(any(), any(), any(), eq(expectedData)); - verify(notificationWriter).saveLog(any(), any()); - } - - @ParameterizedTest(name = "좋아요 수 {0}개 - 마일스톤 미도달 시 알림 미발송") - @ValueSource(longs = {1, 2, 3, 4, 6, 9, 11, 49, 51, 99, 101, 499, 501, 999, 1001}) - @DisplayName("스킵: 마일스톤 미도달 시 알림 미발송") - void givenNonMilestoneCount_whenHandle_thenSkip(long likeCount) { - // given - User postWriter = userWithId("postWriterId"); - User liker = userWithId("likerId"); - Post post = postWithWriter(postWriter); - given(post.getId()).willReturn("postId"); - - given(postReader.findById("postId")).willReturn(post); - given(userReader.findUserById("likerId")).willReturn(liker); - given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); - given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(false); - given(likePostReader.countByPostId("postId")).willReturn(likeCount); - - // when - handler.handle(new PostLikedEvent("postId", "likerId")); - - // then - verify(notificationWriter, never()).save(any()); - } - - @Test - @DisplayName("스킵: 게시글 작성자가 좋아요를 누른 경우 알림 미발송") - void givenPostWriterLiked_whenHandle_thenSkip() { - // given - User postWriter = userWithId("userId"); - Post post = postWithWriter(postWriter); - - given(postReader.findById("postId")).willReturn(post); - given(userReader.findUserById("userId")).willReturn(postWriter); - - // when - handler.handle(new PostLikedEvent("postId", "userId")); - - // then - verify(notificationWriter, never()).save(any()); - } - - @Test - @DisplayName("스킵: 게시글 작성자가 좋아요 알림 설정 OFF면 알림 미발송") - void givenLikeNotificationDisabled_whenHandle_thenSkip() { - // given - User postWriter = userWithId("postWriterId"); - User liker = userWithId("likerId"); - Post post = postWithWriter(postWriter); - - given(postReader.findById("postId")).willReturn(post); - given(userReader.findUserById("likerId")).willReturn(liker); - given(notificationSettingReader.findSettingMap("postWriterId")) - .willReturn(settingMapWith(UserNotificationSettingKey.COMMUNITY_LIKE_ON_MY_POST, false)); - - // when - handler.handle(new PostLikedEvent("postId", "likerId")); - - // then - verify(notificationWriter, never()).save(any()); - } - - @Test - @DisplayName("스킵: 게시글 작성자가 좋아요 누른 유저를 차단한 경우 알림 미발송") - void givenPostWriterBlockedLiker_whenHandle_thenSkip() { - // given - User postWriter = userWithId("postWriterId"); - User liker = userWithId("likerId"); - Post post = postWithWriter(postWriter); - - given(postReader.findById("postId")).willReturn(post); - given(userReader.findUserById("likerId")).willReturn(liker); - given(notificationSettingReader.findSettingMap("postWriterId")).willReturn(settingMapAllOn()); - given(blockReader.existsByBlockerAndBlocked(postWriter, liker)).willReturn(true); - - // when - handler.handle(new PostLikedEvent("postId", "likerId")); - - // then - verify(notificationWriter, never()).save(any()); - } - } - - // ───────────────────────────────────────────────── - // 헬퍼 - // ───────────────────────────────────────────────── - - private User userWithId(String id) { - User user = mock(User.class); - given(user.getId()).willReturn(id); - return user; - } - - /** getWriter()만 stub */ - private Post postWithWriter(User writer) { - Post post = mock(Post.class); - given(post.getWriter()).willReturn(writer); - return post; - } - - private UserNotificationSettingMap settingMapAllOn() { - return UserNotificationSettingMap.ofFull(Map.of()); - } - - private UserNotificationSettingMap settingMapWith(UserNotificationSettingKey key, boolean value) { - return UserNotificationSettingMap.ofFull(Map.of(key, value)); - } -} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListenerTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListenerTest.java new file mode 100644 index 000000000..1f31fecb7 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestonePushListenerTest.java @@ -0,0 +1,65 @@ +package net.causw.app.main.domain.notification.notification.service.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import net.causw.app.main.domain.notification.notification.enums.NoticeType; +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestonePushEvent; +import net.causw.app.main.domain.notification.notification.service.dto.PushNotificationData; +import net.causw.app.main.domain.notification.notification.service.implementation.NotificationPushSender; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.service.implementation.UserReader; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestonePushListenerTest { + + @InjectMocks + private PostLikeMilestonePushListener listener; + + @Mock + private UserReader userReader; + @Mock + private NotificationPushSender notificationPushSender; + + @Test + @DisplayName("DB 커밋 후 작성자에게 푸시를 한 번 요청한다") + void givenCommittedNotification_whenHandle_thenSendPushOnce() { + User recipient = mock(User.class); + PushNotificationData pushData = new PushNotificationData(NoticeType.COMMUNITY, "postId", "boardId"); + PostLikeMilestonePushEvent event = new PostLikeMilestonePushEvent( + "recipientId", + "push title", + "push body", + pushData); + given(userReader.findUserById("recipientId")).willReturn(recipient); + + listener.handle(event); + + verify(notificationPushSender).sendToUser(recipient, "push title", "push body", pushData); + } + + @Test + @DisplayName("푸시 리스너는 DB 트랜잭션 AFTER_COMMIT 단계에서 실행된다") + void handle_shouldRunAfterCommit() throws NoSuchMethodException { + Method handleMethod = PostLikeMilestonePushListener.class + .getMethod("handle", PostLikeMilestonePushEvent.class); + + TransactionalEventListener annotation = handleMethod.getAnnotation(TransactionalEventListener.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListenerTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListenerTest.java new file mode 100644 index 000000000..e96140796 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/listener/PostLikeMilestoneReachedListenerTest.java @@ -0,0 +1,68 @@ +package net.causw.app.main.domain.notification.notification.service.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import net.causw.app.main.domain.notification.notification.event.PostLikeMilestoneReachedEvent; +import net.causw.app.main.domain.notification.notification.service.implementation.PostLikeMilestoneAchievementRecorder; +import net.causw.app.main.domain.notification.notification.service.implementation.PostLikeMilestoneNotificationProcessor; + +@ExtendWith(MockitoExtension.class) +class PostLikeMilestoneReachedListenerTest { + + @InjectMocks + private PostLikeMilestoneReachedListener listener; + + @Mock + private PostLikeMilestoneAchievementRecorder achievementRecorder; + @Mock + private PostLikeMilestoneNotificationProcessor notificationProcessor; + + @Test + @DisplayName("신규 이력의 트랜잭션이 완료되면 알림 처리를 순차 호출한다") + void givenNewAchievement_whenHandle_thenProcessNotification() { + PostLikeMilestoneReachedEvent event = new PostLikeMilestoneReachedEvent("postId", "likerId", 100L); + given(achievementRecorder.record(event)).willReturn(Optional.of("achievementId")); + + listener.handle(event); + + verify(notificationProcessor).process("achievementId"); + } + + @Test + @DisplayName("이미 소비된 마일스톤이면 알림 처리를 호출하지 않는다") + void givenConsumedAchievement_whenHandle_thenSkipNotificationProcessing() { + PostLikeMilestoneReachedEvent event = new PostLikeMilestoneReachedEvent("postId", "likerId", 100L); + given(achievementRecorder.record(event)).willReturn(Optional.empty()); + + listener.handle(event); + + verifyNoInteractions(notificationProcessor); + } + + @Test + @DisplayName("마일스톤 도달 리스너는 좋아요 트랜잭션 AFTER_COMMIT 단계에서 실행된다") + void handle_shouldRunAfterCommit() throws NoSuchMethodException { + Method handleMethod = PostLikeMilestoneReachedListener.class + .getMethod("handle", PostLikeMilestoneReachedEvent.class); + + TransactionalEventListener annotation = handleMethod.getAnnotation(TransactionalEventListener.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicyTest.java b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicyTest.java new file mode 100644 index 000000000..9059d78d1 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/notification/notification/service/policy/LikePostNotificationPolicyTest.java @@ -0,0 +1,24 @@ +package net.causw.app.main.domain.notification.notification.service.policy; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class LikePostNotificationPolicyTest { + + @ParameterizedTest(name = "좋아요 {0}개") + @ValueSource(longs = {1, 2, 3, 4, 5, 10, 50, 100, 500, 1000, 2000, 10000}) + @DisplayName("정책에 정의된 좋아요 수는 마일스톤이다") + void givenMilestoneCount_whenCheck_thenReturnTrue(long likeCount) { + assertThat(LikePostNotificationPolicy.isMilestone(likeCount)).isTrue(); + } + + @ParameterizedTest(name = "좋아요 {0}개") + @ValueSource(longs = {-1, 0, 6, 9, 11, 49, 51, 99, 101, 499, 501, 999, 1001}) + @DisplayName("정책에 정의되지 않은 좋아요 수는 마일스톤이 아니다") + void givenNonMilestoneCount_whenCheck_thenReturnFalse(long likeCount) { + assertThat(LikePostNotificationPolicy.isMilestone(likeCount)).isFalse(); + } +}