diff --git a/src/main/java/com/techfork/domain/recommendation/dto/RecommendedPostDto.java b/src/main/java/com/techfork/domain/recommendation/dto/RecommendedPostDto.java index b1636b2..e9ecdb8 100644 --- a/src/main/java/com/techfork/domain/recommendation/dto/RecommendedPostDto.java +++ b/src/main/java/com/techfork/domain/recommendation/dto/RecommendedPostDto.java @@ -24,4 +24,24 @@ public record RecommendedPostDto( Integer rank, LocalDateTime recommendedAt ) { + public RecommendedPostDto withBookmarkStatus(boolean isBookmarked) { + return new RecommendedPostDto( + id, + postId, + title, + shortSummary, + company, + url, + logoUrl, + thumbnailUrl, + viewCount, + isBookmarked, + publishedAt, + keywords, + similarityScore, + mmrScore, + rank, + recommendedAt + ); + } } diff --git a/src/main/java/com/techfork/domain/recommendation/repository/RecommendedPostRepository.java b/src/main/java/com/techfork/domain/recommendation/repository/RecommendedPostRepository.java index 2e46022..b133241 100644 --- a/src/main/java/com/techfork/domain/recommendation/repository/RecommendedPostRepository.java +++ b/src/main/java/com/techfork/domain/recommendation/repository/RecommendedPostRepository.java @@ -7,27 +7,17 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; import java.util.List; public interface RecommendedPostRepository extends JpaRepository { - - @Query("SELECT rp FROM RecommendedPost rp WHERE rp.user = :user AND rp.recommendedAt < :before") - List findByUserAndRecommendedAtBefore(@Param("user") User user, @Param("before") LocalDateTime before); - - - @Modifying - @Query("DELETE FROM RecommendedPost rp WHERE rp.user = :user AND rp.recommendedAt < :before") - void deleteByUserAndRecommendedAtBefore(@Param("user") User user, @Param("before") LocalDateTime before); - @Query(""" - SELECT rp FROM RecommendedPost rp - JOIN FETCH rp.post p - JOIN FETCH p.techBlog - WHERE rp.user = :user - ORDER BY rp.rankOrder ASC - """) + SELECT rp FROM RecommendedPost rp + JOIN FETCH rp.post p + JOIN FETCH p.techBlog + WHERE rp.user = :user + ORDER BY rp.rankOrder ASC + """) List findByUserOrderByRankAsc(@Param("user") User user); @Modifying diff --git a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java index 59fc816..90540df 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java @@ -14,7 +14,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.stream.Collectors; @Slf4j @Service @@ -33,8 +32,6 @@ public RecommendationListResponse getRecommendations(Long userId) { log.info("사용자 {} 추천 목록 조회: {} 개", userId, recommendedPosts.size()); RecommendationListResponse response = recommendationConverter.toRecommendationListResponse(recommendedPosts); - - // Attach bookmark status response = attachBookmarkStatus(response, userId); return response; @@ -45,34 +42,14 @@ private RecommendationListResponse attachBookmarkStatus(RecommendationListRespon return response; } - // Collect postIds List postIds = response.recommendations().stream() .map(RecommendedPostDto::postId) - .collect(Collectors.toList()); - - // Fetch bookmark status + .toList(); List bookmarkedPostIds = scrabPostRepository.findBookmarkedPostIds(userId, postIds); - // Attach bookmark status to recommendations List updatedRecommendations = response.recommendations().stream() - .map(dto -> RecommendedPostDto.builder() - .id(dto.id()) - .postId(dto.postId()) - .title(dto.title()) - .company(dto.company()) - .url(dto.url()) - .logoUrl(dto.logoUrl()) - .thumbnailUrl(dto.thumbnailUrl()) - .viewCount(dto.viewCount()) - .isBookmarked(bookmarkedPostIds.contains(dto.postId())) - .publishedAt(dto.publishedAt()) - .keywords(dto.keywords()) - .similarityScore(dto.similarityScore()) - .mmrScore(dto.mmrScore()) - .rank(dto.rank()) - .recommendedAt(dto.recommendedAt()) - .build()) - .collect(Collectors.toList()); + .map(dto -> dto.withBookmarkStatus(bookmarkedPostIds.contains(dto.postId()))) + .toList(); return RecommendationListResponse.builder() .recommendations(updatedRecommendations) diff --git a/src/test/java/com/techfork/domain/recommendation/controller/RecommendationControllerIntegrationTest.java b/src/test/java/com/techfork/domain/recommendation/controller/RecommendationControllerIntegrationTest.java new file mode 100644 index 0000000..ddfd408 --- /dev/null +++ b/src/test/java/com/techfork/domain/recommendation/controller/RecommendationControllerIntegrationTest.java @@ -0,0 +1,260 @@ +package com.techfork.domain.recommendation.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techfork.domain.activity.entity.ScrabPost; +import com.techfork.domain.activity.repository.ScrabPostRepository; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.recommendation.entity.RecommendedPost; +import com.techfork.domain.recommendation.repository.RecommendedPostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.Role; +import com.techfork.domain.user.enums.SocialType; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.common.IntegrationTestBase; +import com.techfork.global.security.jwt.JwtDTO; +import com.techfork.global.security.jwt.JwtUtil; +import org.junit.jupiter.api.AfterEach; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * RecommendationController 통합 테스트 + */ +class RecommendationControllerIntegrationTest extends IntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + @Autowired + private RecommendedPostRepository recommendedPostRepository; + + @Autowired + private ScrabPostRepository scrabPostRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtUtil jwtUtil; + + private User testUser; + private String accessToken; + private TechBlog testBlog; + private Post testPost1; + private Post testPost2; + private Post testPost3; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.createSocialUser( + SocialType.KAKAO, + "testSocialId", + "test@example.com", + "profile.jpg" + ); + testUser.updateUser("테스트유저", "test@example.com", "백엔드 개발자입니다."); + testUser = userRepository.save(testUser); + + // JWT 토큰 생성 + JwtDTO tokens = jwtUtil.generateTokens(testUser.getId(), Role.USER); + accessToken = tokens.accessToken(); + + // 테스트 블로그 생성 + testBlog = TechBlog.builder() + .companyName("테스트회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .logoUrl("https://test.com/logo.png") + .build(); + testBlog = techBlogRepository.save(testBlog); + + // 테스트 게시글 3개 생성 + testPost1 = Post.builder() + .title("추천 게시글 1") + .fullContent("게시글 1의 전체 내용입니다.") + .plainContent("게시글 1의 내용") + .summary("게시글 1의 요약") + .shortSummary("게시글 1의 짧은 요약") + .company("테스트회사") + .logoUrl("https://test.com/logo.png") + .thumbnailUrl("https://test.com/thumb1.png") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.now().minusDays(1)) + .crawledAt(LocalDateTime.now()) + .techBlog(testBlog) + .build(); + testPost1 = postRepository.save(testPost1); + + testPost2 = Post.builder() + .title("추천 게시글 2") + .fullContent("게시글 2의 전체 내용입니다.") + .plainContent("게시글 2의 내용") + .summary("게시글 2의 요약") + .shortSummary("게시글 2의 짧은 요약") + .company("테스트회사") + .logoUrl("https://test.com/logo.png") + .thumbnailUrl("https://test.com/thumb2.png") + .url("https://test.com/post/2") + .publishedAt(LocalDateTime.now().minusDays(2)) + .crawledAt(LocalDateTime.now()) + .techBlog(testBlog) + .build(); + testPost2 = postRepository.save(testPost2); + + testPost3 = Post.builder() + .title("추천 게시글 3") + .fullContent("게시글 3의 전체 내용입니다.") + .plainContent("게시글 3의 내용") + .summary("게시글 3의 요약") + .shortSummary("게시글 3의 짧은 요약") + .company("테스트회사") + .logoUrl("https://test.com/logo.png") + .thumbnailUrl("https://test.com/thumb3.png") + .url("https://test.com/post/3") + .publishedAt(LocalDateTime.now().minusDays(3)) + .crawledAt(LocalDateTime.now()) + .techBlog(testBlog) + .build(); + testPost3 = postRepository.save(testPost3); + } + + @AfterEach + void tearDown() { + recommendedPostRepository.deleteAll(); + scrabPostRepository.deleteAll(); + postRepository.deleteAll(); + techBlogRepository.deleteAll(); + userRepository.deleteAll(); + } + + // ===== 추천 게시글 조회 테스트 ===== + + @Test + @DisplayName("추천 게시글 목록 조회 성공 - 빈 목록") + void getRecommendations_Success_Empty() throws Exception { + // When & Then + mockMvc.perform(get("/api/v1/recommendations") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.recommendations").isArray()) + .andExpect(jsonPath("$.data.recommendations").isEmpty()) + .andExpect(jsonPath("$.data.totalCount").value(0)); + } + + @Test + @DisplayName("추천 게시글 목록 조회 성공 - 여러 개") + void getRecommendations_Success_Multiple() throws Exception { + // Given - 추천 게시글 3개 생성 + RecommendedPost rec1 = RecommendedPost.create(testUser, testPost1, 0.9, 0.85, 1); + RecommendedPost rec2 = RecommendedPost.create(testUser, testPost2, 0.8, 0.75, 2); + RecommendedPost rec3 = RecommendedPost.create(testUser, testPost3, 0.7, 0.65, 3); + recommendedPostRepository.saveAll(List.of(rec1, rec2, rec3)); + + // When & Then + mockMvc.perform(get("/api/v1/recommendations") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.recommendations").isArray()) + .andExpect(jsonPath("$.data.recommendations.length()").value(3)) + // 첫 번째 추천 (rank 1) + .andExpect(jsonPath("$.data.recommendations[0].postId").value(testPost1.getId())) + .andExpect(jsonPath("$.data.recommendations[0].title").value("추천 게시글 1")) + .andExpect(jsonPath("$.data.recommendations[0].shortSummary").value("게시글 1의 짧은 요약")) + .andExpect(jsonPath("$.data.recommendations[0].company").value("테스트회사")) + .andExpect(jsonPath("$.data.recommendations[0].url").value("https://test.com/post/1")) + .andExpect(jsonPath("$.data.recommendations[0].logoUrl").value("https://test.com/logo.png")) + .andExpect(jsonPath("$.data.recommendations[0].thumbnailUrl").value("https://test.com/thumb1.png")) + .andExpect(jsonPath("$.data.recommendations[0].viewCount").value(0)) + .andExpect(jsonPath("$.data.recommendations[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.recommendations[0].publishedAt").exists()) + .andExpect(jsonPath("$.data.recommendations[0].keywords").isArray()) + .andExpect(jsonPath("$.data.recommendations[0].similarityScore").value(0.9)) + .andExpect(jsonPath("$.data.recommendations[0].mmrScore").value(0.85)) + .andExpect(jsonPath("$.data.recommendations[0].rank").value(1)) + // 두 번째 추천 (rank 2) + .andExpect(jsonPath("$.data.recommendations[1].rank").value(2)) + // 세 번째 추천 (rank 3) + .andExpect(jsonPath("$.data.recommendations[2].rank").value(3)); + } + + @Test + @DisplayName("추천 게시글 목록 조회 성공 - 랭킹 순으로 정렬") + void getRecommendations_Success_OrderedByRank() throws Exception { + // Given - 의도적으로 순서를 섞어서 저장 + RecommendedPost rec3 = RecommendedPost.create(testUser, testPost3, 0.7, 0.65, 3); + RecommendedPost rec1 = RecommendedPost.create(testUser, testPost1, 0.9, 0.85, 1); + RecommendedPost rec2 = RecommendedPost.create(testUser, testPost2, 0.8, 0.75, 2); + recommendedPostRepository.saveAll(List.of(rec3, rec1, rec2)); + + // When & Then - rank 순서대로 조회되어야 함 + mockMvc.perform(get("/api/v1/recommendations") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendations[0].rank").value(1)) + .andExpect(jsonPath("$.data.recommendations[0].postId").value(testPost1.getId())) + .andExpect(jsonPath("$.data.recommendations[1].rank").value(2)) + .andExpect(jsonPath("$.data.recommendations[1].postId").value(testPost2.getId())) + .andExpect(jsonPath("$.data.recommendations[2].rank").value(3)) + .andExpect(jsonPath("$.data.recommendations[2].postId").value(testPost3.getId())); + } + + // ===== 통합 시나리오 테스트 ===== + + @Test + @DisplayName("통합 시나리오 - 추천 조회 후 북마크 추가 후 다시 조회") + void integrationScenario_GetRecommendations_AddBookmark_GetAgain() throws Exception { + // 1. 추천 게시글 생성 + RecommendedPost rec1 = RecommendedPost.create(testUser, testPost1, 0.9, 0.85, 1); + recommendedPostRepository.save(rec1); + + // 2. 추천 조회 - 북마크 안됨 + mockMvc.perform(get("/api/v1/recommendations") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendations[0].isBookmarked").value(false)); + + // 3. 북마크 추가 + ScrabPost bookmark = ScrabPost.create(testUser, testPost1, LocalDateTime.now()); + scrabPostRepository.save(bookmark); + + // 4. 다시 조회 - 북마크됨 + mockMvc.perform(get("/api/v1/recommendations") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendations[0].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.recommendations[0].shortSummary").value("게시글 1의 짧은 요약")); + } +} \ No newline at end of file diff --git a/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java new file mode 100644 index 0000000..5dda1ad --- /dev/null +++ b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java @@ -0,0 +1,243 @@ +package com.techfork.domain.recommendation.service; + +import com.techfork.domain.activity.repository.ScrabPostRepository; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.recommendation.converter.RecommendationConverter; +import com.techfork.domain.recommendation.dto.RecommendationListResponse; +import com.techfork.domain.recommendation.dto.RecommendedPostDto; +import com.techfork.domain.recommendation.entity.RecommendedPost; +import com.techfork.domain.recommendation.repository.RecommendedPostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.SocialType; +import com.techfork.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecommendationQueryService 단위 테스트") +class RecommendationQueryServiceTest { + + @Mock + private RecommendedPostRepository recommendedPostRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private RecommendationConverter recommendationConverter; + + @Mock + private ScrabPostRepository scrabPostRepository; + + @InjectMocks + private RecommendationQueryService recommendationQueryService; + + private User testUser; + private TechBlog testTechBlog; + private Post post1; + private Post post2; + private Post post3; + private RecommendedPost recommendedPost1; + private RecommendedPost recommendedPost2; + private RecommendedPost recommendedPost3; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.createSocialUser( + SocialType.KAKAO, + "test-social-id", + "test@example.com", + "https://example.com/profile.jpg" + ); + + // 테스트 기술 블로그 생성 + testTechBlog = TechBlog.create( + "테스트 회사", + "https://test.com", + "https://test.com/rss", + "https://test.com/logo.png" + ); + + // 테스트 게시글 생성 + post1 = Post.builder() + .title("게시글 1") + .shortSummary("요약 1") + .url("https://test.com/post1") + .company("테스트 회사") + .publishedAt(LocalDateTime.now().minusDays(1)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + + post2 = Post.builder() + .title("게시글 2") + .shortSummary("요약 2") + .url("https://test.com/post2") + .company("테스트 회사") + .publishedAt(LocalDateTime.now().minusDays(2)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + + post3 = Post.builder() + .title("게시글 3") + .shortSummary("요약 3") + .url("https://test.com/post3") + .company("테스트 회사") + .publishedAt(LocalDateTime.now().minusDays(3)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + + // 테스트 추천 게시글 생성 + recommendedPost1 = RecommendedPost.create(testUser, post1, 0.9, 0.85, 1); + recommendedPost2 = RecommendedPost.create(testUser, post2, 0.8, 0.75, 2); + recommendedPost3 = RecommendedPost.create(testUser, post3, 0.7, 0.65, 3); + } + + @Test + @DisplayName("추천 게시글 목록을 조회한다") + void getRecommendations() { + // given + Long userId = 1L; + List recommendedPosts = List.of( + recommendedPost1, recommendedPost2, recommendedPost3 + ); + + RecommendedPostDto dto1 = createRecommendedPostDto(1L, 101L, "게시글 1", "요약 1", null); + RecommendedPostDto dto2 = createRecommendedPostDto(2L, 102L, "게시글 2", "요약 2", null); + RecommendedPostDto dto3 = createRecommendedPostDto(3L, 103L, "게시글 3", "요약 3", null); + + RecommendationListResponse initialResponse = RecommendationListResponse.builder() + .recommendations(List.of(dto1, dto2, dto3)) + .totalCount(3) + .build(); + + given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); + given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); + given(scrabPostRepository.findBookmarkedPostIds(eq(userId), any())).willReturn(List.of()); + + // when + RecommendationListResponse response = recommendationQueryService.getRecommendations(userId); + + // then + assertThat(response.recommendations()).hasSize(3); + assertThat(response.totalCount()).isEqualTo(3); + assertThat(response.recommendations().get(0).title()).isEqualTo("게시글 1"); + assertThat(response.recommendations().get(0).isBookmarked()).isFalse(); + + verify(userRepository).getReferenceById(userId); + verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); + verify(recommendationConverter).toRecommendationListResponse(recommendedPosts); + verify(scrabPostRepository).findBookmarkedPostIds(eq(userId), any()); + } + + @Test + @DisplayName("북마크한 게시글은 isBookmarked가 true로 설정된다") + void getRecommendations_withBookmarkedPosts() { + // given + Long userId = 1L; + List recommendedPosts = List.of( + recommendedPost1, recommendedPost2, recommendedPost3 + ); + + RecommendedPostDto dto1 = createRecommendedPostDto(1L, 101L, "게시글 1", "요약 1", null); + RecommendedPostDto dto2 = createRecommendedPostDto(2L, 102L, "게시글 2", "요약 2", null); + RecommendedPostDto dto3 = createRecommendedPostDto(3L, 103L, "게시글 3", "요약 3", null); + + RecommendationListResponse initialResponse = RecommendationListResponse.builder() + .recommendations(List.of(dto1, dto2, dto3)) + .totalCount(3) + .build(); + + // 101L, 103L 게시글은 북마크됨 + List bookmarkedPostIds = List.of(101L, 103L); + + given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); + given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); + given(scrabPostRepository.findBookmarkedPostIds(eq(userId), any())).willReturn(bookmarkedPostIds); + + // when + RecommendationListResponse response = recommendationQueryService.getRecommendations(userId); + + // then + assertThat(response.recommendations()).hasSize(3); + assertThat(response.recommendations().get(0).postId()).isEqualTo(101L); + assertThat(response.recommendations().get(0).isBookmarked()).isTrue(); + assertThat(response.recommendations().get(1).postId()).isEqualTo(102L); + assertThat(response.recommendations().get(1).isBookmarked()).isFalse(); + assertThat(response.recommendations().get(2).postId()).isEqualTo(103L); + assertThat(response.recommendations().get(2).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("추천 게시글이 없으면 빈 리스트를 반환한다") + void getRecommendations_emptyList() { + // given + Long userId = 1L; + List emptyList = List.of(); + + RecommendationListResponse emptyResponse = RecommendationListResponse.builder() + .recommendations(List.of()) + .totalCount(0) + .build(); + + given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(emptyList); + given(recommendationConverter.toRecommendationListResponse(emptyList)).willReturn(emptyResponse); + + // when + RecommendationListResponse response = recommendationQueryService.getRecommendations(userId); + + // then + assertThat(response.recommendations()).isEmpty(); + assertThat(response.totalCount()).isZero(); + + verify(userRepository).getReferenceById(userId); + verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); + verify(recommendationConverter).toRecommendationListResponse(emptyList); + verify(scrabPostRepository, never()).findBookmarkedPostIds(any(), any()); + } + + private RecommendedPostDto createRecommendedPostDto( + Long id, Long postId, String title, String shortSummary, Boolean isBookmarked + ) { + return RecommendedPostDto.builder() + .id(id) + .postId(postId) + .title(title) + .shortSummary(shortSummary) + .company("테스트 회사") + .url("https://test.com/post" + postId) + .logoUrl("https://test.com/logo.png") + .thumbnailUrl("https://test.com/thumb.png") + .viewCount(100L) + .isBookmarked(isBookmarked) + .publishedAt(LocalDateTime.now()) + .keywords(List.of("키워드1", "키워드2")) + .similarityScore(0.9) + .mmrScore(0.85) + .rank(id.intValue()) + .recommendedAt(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file