diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index e68157b..181f78a 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -29,6 +29,13 @@ public enum NotificationType { false ), + SEMOFEED_EMOJI( + "세모피드에 반응이 달렸어요", + "{actorName}님이 세모피드에 {emojiType} 반응을 남겼어요", + Set.of("actorId", "actorName", "semoFeedId", "emojiType"), + false + ), + /** * 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도. * 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다. diff --git a/src/main/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationService.java b/src/main/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationService.java new file mode 100644 index 0000000..dc988f1 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationService.java @@ -0,0 +1,47 @@ +package com.semosan.api.domain.semofeed.notification.service; + +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.NotificationService; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SemoFeedNotificationService { + + private final NotificationService notificationService; + private final UserRepository userRepository; + + // 세모피드에 새 이모지 반응이 등록되면 작성자에게 알림을 보냅니다. + public void sendEmojiNotification(SemoFeed semoFeed, User reactor, SemoFeedEmojiType emojiType) { + User receiver = semoFeed.getUser(); + sendIfEligible( + receiver, + reactor, + Map.of( + "actorId", reactor.getId(), + "actorName", reactor.displayName(), + "semoFeedId", semoFeed.getId(), + "emojiType", emojiType.name() + ) + ); + } + + // 본인 반응과 탈퇴한 작성자는 알림 발송 대상에서 제외합니다. + private void sendIfEligible(User receiver, User reactor, Map params) { + if (receiver.getId().equals(reactor.getId())) { + return; + } + if (!userRepository.existsByIdAndDeletedFalse(receiver.getId())) { + return; + } + + notificationService.send(receiver.getId(), NotificationType.SEMOFEED_EMOJI, params); + } +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java index 00f1cb2..87b3b24 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -6,6 +6,7 @@ import com.semosan.api.domain.semofeed.entity.SemoFeed; import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.notification.service.SemoFeedNotificationService; import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; @@ -23,6 +24,7 @@ public class SemoFeedEmojiService { private final SemoFeedEmojiRepository semoFeedEmojiRepository; private final SemoFeedRepository semoFeedRepository; + private final SemoFeedNotificationService semoFeedNotificationService; private final UserReader userReader; @Transactional @@ -36,6 +38,10 @@ public SemoFeedEmojiToggleResponse toggleWithCount( User user = userReader.findActiveUserById(userId); boolean reacted = toggle(semoFeed, user, emojiType); + if (reacted) { + // 새 반응 등록일 때만 작성자 알림을 발송합니다. + semoFeedNotificationService.sendEmojiNotification(semoFeed, user, emojiType); + } long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); return new SemoFeedEmojiToggleResponse(emojiType, reacted, count); diff --git a/src/test/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationServiceTest.java new file mode 100644 index 0000000..53ead05 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationServiceTest.java @@ -0,0 +1,111 @@ +package com.semosan.api.domain.semofeed.notification.service; + +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.NotificationService; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.repository.UserRepository; +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.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SemoFeedNotificationServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SemoFeedNotificationService semoFeedNotificationService; + + @Test + void semoFeedEmojiNotificationTypeRequiresNavigationPayload() { + assertThatCode(() -> NotificationType.SEMOFEED_EMOJI.validate(Map.of( + "actorId", 2L, + "actorName", "reactor", + "semoFeedId", 10L, + "emojiType", "FIRE" + ))).doesNotThrowAnyException(); + } + + @Test + void sendEmojiNotificationSendsToSemoFeedAuthor() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + + when(userRepository.existsByIdAndDeletedFalse(1L)).thenReturn(true); + + semoFeedNotificationService.sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.FIRE); + + verify(notificationService).send( + eq(1L), + eq(NotificationType.SEMOFEED_EMOJI), + argThat(params -> params.get("actorId").equals(2L) + && params.get("actorName").equals("reactor") + && params.get("semoFeedId").equals(10L) + && params.get("emojiType").equals("FIRE")) + ); + } + + @Test + void sendEmojiNotificationSkipsSelfReaction() { + User author = user(1L, "author"); + SemoFeed semoFeed = semoFeed(10L, author); + + semoFeedNotificationService.sendEmojiNotification(semoFeed, author, SemoFeedEmojiType.HEART); + + verify(notificationService, never()).send( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + @Test + void sendEmojiNotificationSkipsInactiveAuthor() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + + when(userRepository.existsByIdAndDeletedFalse(1L)).thenReturn(false); + + semoFeedNotificationService.sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.LAUGH); + + verify(notificationService, never()).send( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any() + ); + } + + private User user(Long id, String nickname) { + User user = User.createTestUser(nickname, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "nickname", nickname); + return user; + } + + private SemoFeed semoFeed(Long id, User user) { + SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png"); + ReflectionTestUtils.setField(semoFeed, "id", id); + return semoFeed; + } +} diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java index fda920b..7839945 100644 --- a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java @@ -4,6 +4,7 @@ import com.semosan.api.domain.semofeed.entity.SemoFeed; import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.notification.service.SemoFeedNotificationService; import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; @@ -20,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +34,9 @@ class SemoFeedEmojiServiceTest { @Mock private SemoFeedRepository semoFeedRepository; + @Mock + private SemoFeedNotificationService semoFeedNotificationService; + @Mock private UserReader userReader; @@ -63,6 +68,7 @@ void toggleWithCountCreatesEmojiReaction() { assertThat(result.reacted()).isTrue(); assertThat(result.count()).isEqualTo(1L); verify(semoFeedEmojiRepository).save(any(SemoFeedEmoji.class)); + verify(semoFeedNotificationService).sendEmojiNotification(semoFeed, reactor, SemoFeedEmojiType.FIRE); } @Test @@ -90,6 +96,7 @@ void toggleWithCountDeletesExistingEmojiReaction() { assertThat(result.reacted()).isFalse(); assertThat(result.count()).isZero(); verify(semoFeedEmojiRepository).delete(existing); + verify(semoFeedNotificationService, never()).sendEmojiNotification(any(), any(), any()); } private User user(Long id, String nickname) {