diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/ArticleKeywordUserMatcherTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/ArticleKeywordUserMatcherTest.java new file mode 100644 index 0000000000..eadb3594a2 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/ArticleKeywordUserMatcherTest.java @@ -0,0 +1,117 @@ +package in.koreatech.koin.unit.domain.community.keyword.service; + +import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +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 in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.keyword.service.ArticleKeywordUserMatcher; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.KeywordFixture; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +public class ArticleKeywordUserMatcherTest { + + @InjectMocks + private ArticleKeywordUserMatcher articleKeywordUserMatcher; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Test + void 매칭된_키워드로_사용자별_키워드를_조회한다() { + List matchedKeywords = List.of("수강신청", "장학금"); + when(articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + KOREATECH, + matchedKeywords + )).thenReturn(List.of()); + + articleKeywordUserMatcher.findKeywordsByUserId(KOREATECH, matchedKeywords); + + verify(articleKeywordUserMapRepository) + .findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn(KOREATECH, matchedKeywords); + } + + @Test + void 키워드_사용자_매핑을_사용자별_키워드로_변환한다() { + User firstUser = UserFixture.id_설정_코인_유저(1); + User secondUser = UserFixture.id_설정_코인_유저(2); + ArticleKeyword firstKeyword = KeywordFixture.공지_키워드("수강신청"); + ArticleKeyword secondKeyword = KeywordFixture.공지_키워드("장학금"); + List matchedKeywords = List.of("수강신청", "장학금"); + when(articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + KOREATECH, + matchedKeywords + )).thenReturn(List.of( + KeywordFixture.키워드_사용자_매핑(firstUser, firstKeyword), + KeywordFixture.키워드_사용자_매핑(secondUser, secondKeyword) + )); + + Map keywordByUserId = articleKeywordUserMatcher.findKeywordsByUserId( + KOREATECH, + matchedKeywords + ); + + assertThat(keywordByUserId).containsExactlyInAnyOrderEntriesOf(Map.of( + firstUser.getId(), firstKeyword.getKeyword(), + secondUser.getId(), secondKeyword.getKeyword() + )); + } + + @Test + void 한_사용자가_여러_키워드에_매칭되면_더_긴_키워드를_선택한다() { + User user = UserFixture.id_설정_코인_유저(1); + ArticleKeyword shortKeyword = KeywordFixture.공지_키워드("신청"); + ArticleKeyword longKeyword = KeywordFixture.공지_키워드("수강신청"); + List matchedKeywords = List.of("신청", "수강신청"); + when(articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + KOREATECH, + matchedKeywords + )).thenReturn(List.of( + KeywordFixture.키워드_사용자_매핑(user, shortKeyword), + KeywordFixture.키워드_사용자_매핑(user, longKeyword) + )); + + Map keywordByUserId = articleKeywordUserMatcher.findKeywordsByUserId( + KOREATECH, + matchedKeywords + ); + + assertThat(keywordByUserId).containsExactly(Map.entry(user.getId(), longKeyword.getKeyword())); + } + + @Test + void 한_사용자가_같은_길이의_키워드에_매칭되면_먼저_조회된_키워드를_유지한다() { + User user = UserFixture.id_설정_코인_유저(1); + ArticleKeyword firstKeyword = KeywordFixture.공지_키워드("장학금"); + ArticleKeyword secondKeyword = KeywordFixture.공지_키워드("생활관"); + List matchedKeywords = List.of("장학금", "생활관"); + when(articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn( + KOREATECH, + matchedKeywords + )).thenReturn(List.of( + KeywordFixture.키워드_사용자_매핑(user, firstKeyword), + KeywordFixture.키워드_사용자_매핑(user, secondKeyword) + )); + + Map keywordByUserId = articleKeywordUserMatcher.findKeywordsByUserId( + KOREATECH, + matchedKeywords + ); + + assertThat(keywordByUserId).containsExactly(Map.entry(user.getId(), firstKeyword.getKeyword())); + } + +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java new file mode 100644 index 0000000000..fae68a91a6 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -0,0 +1,168 @@ +package in.koreatech.koin.unit.domain.community.keyword.service; + +import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.readmodel.ArticleSummary; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.keyword.service.ArticleKeywordUserMatcher; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +public class KeywordServiceTest { + + @InjectMocks + private KeywordService keywordService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordSuggestRepository articleKeywordSuggestRepository; + + @Mock + private ArticleRepository articleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private KeywordExtractor keywordExtractor; + + @Mock + private ArticleKeywordUserMatcher articleKeywordUserMatcher; + + @Test + void 공지사항_ID_목록으로_게시글_요약을_조회한다() { + Set articleIds = Set.of(1, 2, 3); + KeywordNotificationRequest request = new KeywordNotificationRequest(articleIds); + when(articleRepository.findAllSummariesByIdIn(articleIds)).thenReturn(List.of()); + + keywordService.sendKeywordNotification(request); + + verify(articleRepository).findAllSummariesByIdIn(articleIds); + } + + @Test + void 게시글_제목에_매칭된_키워드가_없으면_이벤트를_발행하지_않는다() { + KeywordNotificationRequest request = new KeywordNotificationRequest(Set.of(1)); + ArticleSummary articleSummary = new ArticleSummary(1, 4, "일반 공지입니다"); + when(articleRepository.findAllSummariesByIdIn(request.updateNotification())) + .thenReturn(List.of(articleSummary)); + when(keywordExtractor.matchKeywords(articleSummary.title(), KOREATECH)) + .thenReturn(List.of()); + + keywordService.sendKeywordNotification(request); + + verifyNoInteractions(articleKeywordUserMatcher); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void 매칭된_키워드를_가진_사용자가_없으면_이벤트를_발행하지_않는다() { + KeywordNotificationRequest request = new KeywordNotificationRequest(Set.of(1)); + ArticleSummary articleSummary = new ArticleSummary(1, 4, "수강신청 안내"); + List matchedKeywords = List.of("수강신청"); + when(articleRepository.findAllSummariesByIdIn(request.updateNotification())) + .thenReturn(List.of(articleSummary)); + when(keywordExtractor.matchKeywords(articleSummary.title(), KOREATECH)) + .thenReturn(matchedKeywords); + when(articleKeywordUserMatcher.findKeywordsByUserId(KOREATECH, matchedKeywords)) + .thenReturn(Map.of()); + + keywordService.sendKeywordNotification(request); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void 매칭된_키워드를_가진_사용자가_있으면_키워드_이벤트를_발행한다() { + KeywordNotificationRequest request = new KeywordNotificationRequest(Set.of(1)); + ArticleSummary articleSummary = new ArticleSummary(1, 4, "수강신청 안내"); + List matchedKeywords = List.of("수강신청"); + Map keywordByUserId = Map.of( + 10, "수강신청", + 20, "신청" + ); + when(articleRepository.findAllSummariesByIdIn(request.updateNotification())) + .thenReturn(List.of(articleSummary)); + when(keywordExtractor.matchKeywords(articleSummary.title(), KOREATECH)) + .thenReturn(matchedKeywords); + when(articleKeywordUserMatcher.findKeywordsByUserId(KOREATECH, matchedKeywords)) + .thenReturn(keywordByUserId); + + keywordService.sendKeywordNotification(request); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass( + KoreatechArticleKeywordEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + KoreatechArticleKeywordEvent event = eventCaptor.getValue(); + + assertThat(event.articleId()).isEqualTo(articleSummary.id()); + assertThat(event.boardId()).isEqualTo(articleSummary.boardId()); + assertThat(event.articleTitle()).isEqualTo(articleSummary.title()); + assertThat(event.matchedKeywordUsers().keywordByUserId()).isEqualTo(keywordByUserId); + } + + @Test + void 여러_게시글_중_매칭된_사용자가_있는_게시글만_이벤트를_발행한다() { + KeywordNotificationRequest request = new KeywordNotificationRequest(Set.of(1, 2, 3)); + ArticleSummary unmatchedArticle = new ArticleSummary(1, 4, "일반 공지입니다"); + ArticleSummary noUserArticle = new ArticleSummary(2, 4, "장학금 안내"); + ArticleSummary matchedArticle = new ArticleSummary(3, 4, "수강신청 안내"); + List scholarshipKeywords = List.of("장학금"); + List courseRegistrationKeywords = List.of("수강신청"); + Map keywordByUserId = Map.of(10, "수강신청"); + when(articleRepository.findAllSummariesByIdIn(request.updateNotification())) + .thenReturn(List.of(unmatchedArticle, noUserArticle, matchedArticle)); + when(keywordExtractor.matchKeywords(unmatchedArticle.title(), KOREATECH)) + .thenReturn(List.of()); + when(keywordExtractor.matchKeywords(noUserArticle.title(), KOREATECH)) + .thenReturn(scholarshipKeywords); + when(keywordExtractor.matchKeywords(matchedArticle.title(), KOREATECH)) + .thenReturn(courseRegistrationKeywords); + when(articleKeywordUserMatcher.findKeywordsByUserId(KOREATECH, scholarshipKeywords)) + .thenReturn(Map.of()); + when(articleKeywordUserMatcher.findKeywordsByUserId(KOREATECH, courseRegistrationKeywords)) + .thenReturn(keywordByUserId); + + keywordService.sendKeywordNotification(request); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass( + KoreatechArticleKeywordEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + KoreatechArticleKeywordEvent event = eventCaptor.getValue(); + + assertThat(event.articleId()).isEqualTo(matchedArticle.id()); + assertThat(event.boardId()).isEqualTo(matchedArticle.boardId()); + assertThat(event.articleTitle()).isEqualTo(matchedArticle.title()); + assertThat(event.matchedKeywordUsers().keywordByUserId()).isEqualTo(keywordByUserId); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/KeywordNotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/KeywordNotificationServiceTest.java new file mode 100644 index 0000000000..116579e14e --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/KeywordNotificationServiceTest.java @@ -0,0 +1,179 @@ +package in.koreatech.koin.unit.domain.notification.service; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.KeywordNotificationService; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.KeywordFixture; +import in.koreatech.koin.unit.fixture.NotificationFixture; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +public class KeywordNotificationServiceTest { + + @InjectMocks + private KeywordNotificationService keywordNotificationService; + + @Mock + private NotificationFactory notificationFactory; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private NotificationService notificationService; + + @Test + void 매칭된_사용자가_없으면_알림을_처리하지_않는다() { + KoreatechArticleKeywordEvent event = KeywordFixture.공지_키워드_이벤트( + 1, + 4, + "수강신청 안내", + Map.of() + ); + + keywordNotificationService.notifyArticleKeyword(event); + + verifyNoInteractions(notificationSubscribeRepository, notificationFactory, notificationService); + } + + @Test + void 매칭된_사용자의_공지_키워드_구독을_조회한다() { + KoreatechArticleKeywordEvent event = KeywordFixture.공지_키워드_이벤트( + 1, + 4, + "수강신청 안내", + Map.of( + 1, "수강신청", + 2, "장학금" + ) + ); + when(notificationSubscribeRepository.findArticleKeywordSubscribesByUserIdIn( + eq(ARTICLE_KEYWORD), + anyList() + )).thenReturn(List.of()); + + keywordNotificationService.notifyArticleKeyword(event); + + ArgumentCaptor> userIdsCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationSubscribeRepository) + .findArticleKeywordSubscribesByUserIdIn(eq(ARTICLE_KEYWORD), userIdsCaptor.capture()); + assertThat(userIdsCaptor.getValue()).containsExactlyInAnyOrder(1, 2); + } + + @Test + void 구독자별로_키워드_알림을_생성하고_푸시한다() { + User firstUser = UserFixture.id_설정_코인_유저(1); + User secondUser = UserFixture.id_설정_코인_유저(2); + NotificationSubscribe firstSubscribe = NotificationFixture.공지_키워드_구독(firstUser); + NotificationSubscribe secondSubscribe = NotificationFixture.공지_키워드_구독(secondUser); + KoreatechArticleKeywordEvent event = KeywordFixture.공지_키워드_이벤트( + 100, + 4, + "수강신청 안내", + Map.of( + firstUser.getId(), "수강신청", + secondUser.getId(), "신청" + ) + ); + Notification firstNotification = NotificationFixture.키워드_알림(firstUser); + Notification secondNotification = NotificationFixture.키워드_알림(secondUser); + when(notificationSubscribeRepository.findArticleKeywordSubscribesByUserIdIn( + eq(ARTICLE_KEYWORD), + anyList() + )).thenReturn(List.of(firstSubscribe, secondSubscribe)); + when(notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + "수강신청", + event.articleTitle(), + event.boardId(), + firstUser + )).thenReturn(firstNotification); + when(notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + "신청", + event.articleTitle(), + event.boardId(), + secondUser + )).thenReturn(secondNotification); + + keywordNotificationService.notifyArticleKeyword(event); + + ArgumentCaptor> notificationsCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationService).pushNotifications(notificationsCaptor.capture()); + assertThat(notificationsCaptor.getValue()).containsExactly(firstNotification, secondNotification); + } + + @Test + void 매칭된_사용자라도_구독자가_아니면_알림을_생성하지_않는다() { + User subscribedUser = UserFixture.id_설정_코인_유저(1); + KoreatechArticleKeywordEvent event = KeywordFixture.공지_키워드_이벤트( + 100, + 4, + "수강신청 안내", + Map.of( + subscribedUser.getId(), "수강신청", + 2, "장학금" + ) + ); + NotificationSubscribe subscribe = NotificationFixture.공지_키워드_구독(subscribedUser); + Notification notification = NotificationFixture.키워드_알림(subscribedUser); + when(notificationSubscribeRepository.findArticleKeywordSubscribesByUserIdIn( + eq(ARTICLE_KEYWORD), + anyList() + )).thenReturn(List.of(subscribe)); + when(notificationFactory.generateKeywordNotification( + KEYWORD, + event.articleId(), + "수강신청", + event.articleTitle(), + event.boardId(), + subscribedUser + )).thenReturn(notification); + + keywordNotificationService.notifyArticleKeyword(event); + + verify(notificationFactory).generateKeywordNotification( + KEYWORD, + event.articleId(), + "수강신청", + event.articleTitle(), + event.boardId(), + subscribedUser + ); + verify(notificationFactory, never()).generateKeywordNotification( + eq(KEYWORD), + eq(event.articleId()), + eq("장학금"), + eq(event.articleTitle()), + eq(event.boardId()), + any(User.class) + ); + ArgumentCaptor> notificationsCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationService).pushNotifications(notificationsCaptor.capture()); + assertThat(notificationsCaptor.getValue()).containsExactly(notification); + } + +} diff --git a/src/test/java/in/koreatech/koin/unit/fixture/KeywordFixture.java b/src/test/java/in/koreatech/koin/unit/fixture/KeywordFixture.java new file mode 100644 index 0000000000..e817b492d5 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/fixture/KeywordFixture.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.unit.fixture; + +import static in.koreatech.koin.domain.community.keyword.enums.KeywordCategory.KOREATECH; + +import java.util.Map; + +import in.koreatech.koin.common.event.KoreatechArticleKeywordEvent; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; +import in.koreatech.koin.domain.user.model.User; + +public class KeywordFixture { + + private KeywordFixture() {} + + public static ArticleKeyword 공지_키워드(String keyword) { + return ArticleKeyword.builder() + .keyword(keyword) + .category(KOREATECH) + .build(); + } + + public static ArticleKeywordUserMap 키워드_사용자_매핑(User user, ArticleKeyword articleKeyword) { + return ArticleKeywordUserMap.builder() + .user(user) + .articleKeyword(articleKeyword) + .build(); + } + + public static KoreatechArticleKeywordEvent 공지_키워드_이벤트( + Integer articleId, + Integer boardId, + String articleTitle, + Map keywordByUserId + ) { + return KoreatechArticleKeywordEvent.of( + articleId, + boardId, + articleTitle, + keywordByUserId + ); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/fixture/NotificationFixture.java b/src/test/java/in/koreatech/koin/unit/fixture/NotificationFixture.java new file mode 100644 index 0000000000..816577f2d7 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/fixture/NotificationFixture.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.unit.fixture; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.user.model.User; + +public class NotificationFixture { + + private NotificationFixture() {} + + public static NotificationSubscribe 공지_키워드_구독(User user) { + return NotificationSubscribe.builder() + .subscribeType(ARTICLE_KEYWORD) + .user(user) + .build(); + } + + public static Notification 키워드_알림(User user) { + return Notification.of( + KEYWORD, + "koin://keyword", + "수강신청 안내", + "방금 등록된 수강신청 공지를 확인해보세요!", + null, + user + ); + } +}