diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 7fd6c91..6c1222d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -106,8 +106,6 @@ public CursorPageResponseInterestDto getInterests(Long userId, Slice slices = interestRepository.findAll( keyword, orderBy, direction, cursor, after, limit); - log.info("REQ userId={}, keyword={}, orderBy={}, direction={}, cursor={}, after={}, limit={}", - userId, keyword, orderBy, direction, cursor, after, limit); List interests = slices.getContent(); @@ -126,9 +124,6 @@ public CursorPageResponseInterestDto getInterests(Long userId, boolean subscribedByMe = subscribedIds.contains(interest.getId()); InterestDto dto = interestMapper.toInterestDto(interest, keywords, subscribedByMe, subscriberCount); - - log.info("DBG dto id={}, name={}, subscriberCount={} subscribedByMe={}", - dto.id(), dto.name(), dto.subscriberCount(), dto.subscribedByMe()); interestDtos.add(dto); } diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java b/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java new file mode 100644 index 0000000..f642540 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/TestInterestForm.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.interest; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.test.util.ReflectionTestUtils; + +public class TestInterestForm { + + // interestId Long 생성기 + private static Long generatedId(){ + return new AtomicLong(1).getAndIncrement(); + } + + public static Interest create(String name, List keywords) { + Interest interest = Interest.create(name); + + for (String keyword : keywords) { + interest.addKeyword(new Keyword(keyword)); + } + ReflectionTestUtils.setField(interest, "id", generatedId()); + return interest; + } +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java new file mode 100644 index 0000000..d84cf96 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomTest.java @@ -0,0 +1,146 @@ +package com.monew.monew_api.interest.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import com.querydsl.core.types.Order; +import java.time.LocalDateTime; +import java.util.Comparator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; + + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +public class InterestRepositoryCustomTest { + + @Qualifier("interestRepositoryCustomImpl") + @Autowired + InterestRepositoryCustom interestRepositoryCustom; + + @Autowired + TestEntityManager em; + + @BeforeEach + void setUp() { + Interest i1 = Interest.create("interest1"); + Interest i2 = Interest.create("interest2"); + Interest i3 = Interest.create("interest3"); + + // i1: 3명 i2: 2명 i3: 1명 구독 + i1.addSubscriberCount(); + i1.addSubscriberCount(); + i1.addSubscriberCount(); + i2.addSubscriberCount(); + i2.addSubscriberCount(); + i3.addSubscriberCount(); + + em.persist(i1); + em.persist(i2); + em.persist(i3); + + Keyword k1 = new Keyword("keyword1"); + Keyword k2 = new Keyword("keyword2"); + Keyword k3 = new Keyword("keyword3"); + Keyword k4 = new Keyword("keyword4"); + + // i1: k1,k2 i2: k2,k3 i3: k4 + i1.addKeyword(k1); + i1.addKeyword(k2); + i2.addKeyword(k2); + i2.addKeyword(k3); + i3.addKeyword(k4); + + em.persist(k1); + em.persist(k2); + em.persist(k3); + em.persist(k4); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("관심사 전체 조회 - name ASC") + void testFindAllNameASC() { + String keyword = null; + InterestOrderBy orderBy = InterestOrderBy.name; + Order direction = Order.ASC; + String cursor = null; + LocalDateTime after = null; + int limit = 2; + + Slice result = interestRepositoryCustom.findAll( + keyword, orderBy, direction, cursor, after, limit + ); + + assertThat(result).hasSize(2); + assertThat(result.hasNext()).isTrue(); + assertThat(result.getContent()) + .isSortedAccordingTo(Comparator.comparing(Interest::getName)); + + } + + @Test + @DisplayName("검색어로 관심사 조회 - subscriberCount DESC") + void testFindAllSubscriberCountDESC() { + String keyword = "interest1"; + InterestOrderBy orderBy = InterestOrderBy.subscriberCount; + Order direction = Order.DESC; + int limit = 6; + + Slice result = interestRepositoryCustom.findAll( + keyword, orderBy, direction, null, null, limit + ); + + assertThat(result).isNotEmpty(); + assertThat(result.getContent()) + .allMatch(i -> i.getName().contains("interest1")); + } + + @Test + @DisplayName("커서 조회 확인 - subscriberCount ASC") + void testFindAllSubscriberCountASCWithCursor() { + Slice firstSlice = interestRepositoryCustom.findAll( + null, InterestOrderBy.subscriberCount, Order.ASC, null, null, 2 + ); + assertThat(firstSlice).hasSize(2); + assertThat(firstSlice.hasNext()).isTrue(); + + Interest last = firstSlice.getContent().get(1); + String nextCursor = String.valueOf(last.getSubscriberCount()); + LocalDateTime after = last.getCreatedAt(); + + Slice secondSlice = interestRepositoryCustom.findAll( + null, InterestOrderBy.subscriberCount, Order.DESC, nextCursor, after, 2 + ); + assertThat(secondSlice).isNotEmpty(); + } + + @Test + @DisplayName("관심사 전체 카운트") + void testFindAllSubscriberCount() { + long count = interestRepositoryCustom.countFilteredTotalElements(null); + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("검색어로 관심사 카운트") + void testCountFilteredTotalElementsWithKeyword() { + long count = interestRepositoryCustom.countFilteredTotalElements("keyword1"); + assertThat(count).isEqualTo(1); + } + +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java new file mode 100644 index 0000000..17f2c18 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/repository/KeywordRepositoryTest.java @@ -0,0 +1,52 @@ +package com.monew.monew_api.interest.repository; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +public class KeywordRepositoryTest { + + @Autowired + KeywordRepository keywordRepository; + + @Autowired + InterestRepository interestRepository; + + @Autowired + EntityManager em; + + @DisplayName("관심사 안에 포함되지 않는 키워드 조회") + @Test + public void findOrphanKeywordsIn() { + Keyword keyword1 = keywordRepository.save(new Keyword("keyword1")); + Keyword keyword2 = keywordRepository.save(new Keyword("keyword2")); + Keyword keyword3 = keywordRepository.save(new Keyword("keyword3")); // 고아 키워드 + + Interest interest = Interest.create("interest1"); + interest.addKeyword(keyword1); + interest.addKeyword(keyword2); + interestRepository.saveAndFlush(interest); + + em.flush(); + em.clear(); + + List orphanKeywords = keywordRepository.findOrphanKeywordsIn( + List.of(keyword1, keyword2, keyword3)); + + assertThat(orphanKeywords).hasSize(1); + assertThat(orphanKeywords.get(0).getKeyword()).isEqualTo("keyword3"); + } +} diff --git a/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java new file mode 100644 index 0000000..9b69ab2 --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/interest/service/InterestServiceTest.java @@ -0,0 +1,285 @@ +package com.monew.monew_api.interest.service; + +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.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.interest.event.InterestUpdatedEvent; +import com.monew.monew_api.user.User; +import com.monew.monew_api.interest.TestInterestForm; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.mapper.InterestMapper; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.querydsl.core.types.Order; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort.Direction; + +@ExtendWith(MockitoExtension.class) +public class InterestServiceTest { + + @Mock + InterestRepository interestRepository; + + @Mock + KeywordRepository keywordRepository; + + @Mock + SubscribeRepository subscribeRepository; + + @Mock + ArticleRepository articleRepository; + + @Mock + InterestArticlesRepository interestArticlesRepository; + + @Mock + InterestArticleKeywordRepository interestArticleKeywordRepository; + + @Mock + InterestMapper interestMapper; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + InterestServiceImpl interestService; + + @DisplayName("관심사 생성 실패 - 유사도 0.8이상이면 중복 예외") + @Test + void createInterest_fail() { + String newName = "interest1"; + List keywords = List.of("keyword1", "keyword2"); + InterestRegisterRequest request = new InterestRegisterRequest(newName, keywords); + + Interest existing = TestInterestForm.create("interest2", List.of()); + when(interestRepository.findAll()).thenReturn(List.of(existing)); + + assertThatThrownBy(() -> interestService.createInterest(request)) + .isInstanceOf(InterestDuplicatedException.class); + + verify(interestRepository, never()).save(any(Interest.class)); + verify(keywordRepository, never()).save(any(Keyword.class)); + + } + + @DisplayName("관심사 생성 성공 - 유사도 중복 없음") + @Test + void createInterest_success() { + String interestName = "interest1"; + List keywords = List.of("keyword1", "keyword2"); + InterestRegisterRequest request = new InterestRegisterRequest(interestName, keywords); + Interest snapshot = TestInterestForm.create(interestName, keywords); + + // 유사도 검사 통과 + when(interestRepository.findAll()).thenReturn(Collections.emptyList()); + // 키워드 조회 및 저장 + when(keywordRepository.findByKeyword("keyword1")).thenReturn(Optional.empty()); + when(keywordRepository.findByKeyword("keyword2")).thenReturn(Optional.empty()); + when(keywordRepository.save(any(Keyword.class))) + .thenAnswer(invocationOnMock -> { + return invocationOnMock.getArgument(0); + }); + when(interestRepository.save(any(Interest.class))) + .thenReturn(snapshot); + + InterestDto expected = new InterestDto( + null, + interestName, + List.of("keyword1", "keyword2"), + 0L, + false); + + when(interestMapper.toDto(eq(snapshot), anyList(), eq(false))).thenReturn(expected); + + InterestDto result = interestService.createInterest(request); + + assertThat(result.name()).isEqualTo(interestName); + assertThat(result.keywords()).contains("keyword1", "keyword2"); + assertThat(result.subscriberCount()).isEqualTo(0); + + verify(interestRepository).save(any(Interest.class)); + } + + @DisplayName("관심사 목록 조회 - name DESC") + @Test + void getInterests() { + User user = new User("user@test.com", "user", "password"); + Long userId = user.getId(); + + CursorPageRequestInterestDto request = new CursorPageRequestInterestDto( + null, + InterestOrderBy.name, Order.DESC, null, null, 3); + + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Slice slices = new SliceImpl<>(List.of(interest), + PageRequest.of(0, 3), false); + + when(interestRepository.findAll(request.keyword(), + request.orderBy(), request.direction(), + request.cursor(), request.after(), + request.limit())).thenReturn(slices); + + when(interestRepository.countFilteredTotalElements(any())).thenReturn(1L); + + CursorPageResponseInterestDto result = interestService.getInterests(userId, request); + + assertThat(result.content()).hasSize(1); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.hasNext()).isEqualTo(false); + verify(interestRepository).findAll(request.keyword(), + request.orderBy(), request.direction(), + request.cursor(), request.after(), + request.limit()); + } + + @DisplayName("관심사 수정 시 키워드 추가/삭제 - 관련 기사 없음") + @Test + void updateInterestKeywords() { + // keyword1 삭제하고 keyword2 추가 + String name = "interest1"; + Interest interest = TestInterestForm.create(name, List.of("keyword1")); + InterestUpdateRequest request = new InterestUpdateRequest(List.of("keyword2")); + + when(interestRepository.findById(any(Long.class))) + .thenReturn(Optional.of(interest)); + + when(keywordRepository.findAllByKeywordIn(argThat(list -> + list.size() == 1 && list.contains("keyword2") + ))).thenReturn(List.of()); + + when(keywordRepository.save(any(Keyword.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + when(interestArticleKeywordRepository.findArticleIdsByKeywordIds(anyList())) + .thenReturn(Collections.emptyList()); + + // 고아 키워드 삭제: keyword1 + when(keywordRepository.findOrphanKeywordsIn(anyList())) + .thenReturn(List.of(new Keyword("keyword1"))); + + when(interestMapper.toDto(eq(interest), anyList(), eq(false))) + .thenAnswer(inv -> { + Interest it = inv.getArgument(0); + @SuppressWarnings("unchecked") + List kws = inv.getArgument(1); + return new InterestDto(it.getId(), it.getName(), kws, (long) it.getSubscriberCount(), false); + }); + + InterestDto result = interestService.updateInterestKeywords(request, interest.getId()); + + assertThat(result.keywords()).containsExactly("keyword2"); + verify(keywordRepository).save(any(Keyword.class)); + verify(keywordRepository).deleteAll(any()); + verify(interestArticleKeywordRepository).findArticleIdsByKeywordIds(anyList()); + verify(interestArticleKeywordRepository, never()).findArticlesUsedElsewhere(anyList(), anyList(), anyLong()); + verify(articleRepository, never()).markAsDeleted(anyList()); + + // 이벤트 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + Object published = eventCaptor.getValue(); + assertThat(published).isInstanceOf(InterestUpdatedEvent.class); + + InterestUpdatedEvent ev = (InterestUpdatedEvent) published; + assertThat(ev.interestId()).isEqualTo(interest.getId()); + assertThat(ev.newKeywords()).containsExactly("keyword2"); + } + + @DisplayName("관심사 삭제- 관련 기사 없으면 바로 삭제") + @Test + void deleteInterest_noArticles() { + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Long interestId = interest.getId(); + + when(interestRepository.findById(any(Long.class))).thenReturn(Optional.of(interest)); + when(interestArticlesRepository.findArticleIdsByInterestId(interestId)) + .thenReturn(List.of()); + + interestService.deleteInterest(interestId); + + verify(interestRepository).delete(interest); + } + + @DisplayName("관심사 삭제 - 일부 기사만 다른 관심사에서 사용 중이면 나머지만 논리 삭제 후 바로 삭제") + @Test + void deleteInterest_someUsedElsewhere() { + Interest interest = TestInterestForm.create("interest1", List.of("k1", "k2")); + Long interestId = interest.getId(); + + when(interestRepository.findById(interestId)).thenReturn(Optional.of(interest)); + + // 연결된 기사[1,2,3] 중 [2]는 다른 관심사에서도 사용됨 + List articleIds = List.of(1L, 2L, 3L); + when(interestArticlesRepository.findArticleIdsByInterestId(interestId)) + .thenReturn(articleIds); + when(interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId)) + .thenReturn(List.of(2L)); + + interestService.deleteInterest(interestId); + + // 논리 삭제 대상: [1,3] + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(articleRepository).markAsDeleted(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(1L, 3L); + + verify(interestRepository).delete(interest); + + // 이벤트 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + Object published = eventCaptor.getValue(); + assertThat(published).isInstanceOf(InterestDeletedEvent.class); + InterestDeletedEvent ev = (InterestDeletedEvent) published; + assertThat(ev.interestId()).isEqualTo(interestId); + + // then 4) 연관 리포지토리 호출 인자 검증(의도 확인) + verify(interestArticlesRepository).findArticleIdsByInterestId(interestId); + verify(interestArticlesRepository) + .findArticleIdsUsedByOtherInterests(articleIds, interestId); + + // then 5) 불필요한 호출이 없는지(선택적 강화) + verify(interestRepository, never()).deleteById(anyLong()); + verifyNoMoreInteractions(articleRepository, eventPublisher); + } +}