From 8eae529c85910e664ac244cf7169db8189926012 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 14:19:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/enums/NotificationType.java | 7 +++ .../service/SemoFeedNotificationService.java | 45 +++++++++++++++++++ .../service/SemoFeedEmojiService.java | 5 +++ .../service/SemoFeedEmojiServiceTest.java | 7 +++ 4 files changed, 64 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationService.java 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..00ff56e 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("actorName", "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..f203bbc --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationService.java @@ -0,0 +1,45 @@ +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..483a352 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,9 @@ 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/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) { From 8817dbb09772edc7d02f12041076771ee9434e86 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 14:19:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SemoFeedNotificationServiceTest.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/test/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationServiceTest.java 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..bdf9944 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/notification/service/SemoFeedNotificationServiceTest.java @@ -0,0 +1,98 @@ +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 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 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; + } +} From 1392d86611154dfa0d3d4d9e92e1e7ccb1bc0345 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 14:25:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=84=B8=EB=AA=A8=ED=94=BC=EB=93=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=95=8C=EB=A6=BC=20=ED=95=84?= =?UTF-8?q?=EC=88=98=20=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/enums/NotificationType.java | 2 +- .../service/SemoFeedNotificationService.java | 2 ++ .../semofeed/service/SemoFeedEmojiService.java | 1 + .../service/SemoFeedNotificationServiceTest.java | 13 +++++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) 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 00ff56e..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 @@ -32,7 +32,7 @@ public enum NotificationType { SEMOFEED_EMOJI( "세모피드에 반응이 달렸어요", "{actorName}님이 세모피드에 {emojiType} 반응을 남겼어요", - Set.of("actorName", "emojiType"), + Set.of("actorId", "actorName", "semoFeedId", "emojiType"), false ), 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 index f203bbc..dc988f1 100644 --- 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 @@ -18,6 +18,7 @@ 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( @@ -32,6 +33,7 @@ public void sendEmojiNotification(SemoFeed semoFeed, User reactor, SemoFeedEmoji ); } + // 본인 반응과 탈퇴한 작성자는 알림 발송 대상에서 제외합니다. private void sendIfEligible(User receiver, User reactor, Map params) { if (receiver.getId().equals(reactor.getId())) { return; 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 483a352..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 @@ -39,6 +39,7 @@ public SemoFeedEmojiToggleResponse toggleWithCount( boolean reacted = toggle(semoFeed, user, emojiType); if (reacted) { + // 새 반응 등록일 때만 작성자 알림을 발송합니다. semoFeedNotificationService.sendEmojiNotification(semoFeed, user, emojiType); } long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); 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 index bdf9944..53ead05 100644 --- 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 @@ -14,6 +14,9 @@ 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; @@ -32,6 +35,16 @@ class SemoFeedNotificationServiceTest { @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");