diff --git a/src/main/java/com/techfork/domain/post/controller/PostControllerV2.java b/src/main/java/com/techfork/domain/post/controller/PostControllerV2.java index c0d37ea..2fd1197 100644 --- a/src/main/java/com/techfork/domain/post/controller/PostControllerV2.java +++ b/src/main/java/com/techfork/domain/post/controller/PostControllerV2.java @@ -1,18 +1,26 @@ package com.techfork.domain.post.controller; import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.service.PostQueryService; import com.techfork.global.common.code.SuccessCode; import com.techfork.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDateTime; +import java.util.List; + @Tag(name = "Post V2", description = "게시글 API V2") @Slf4j @RestController @@ -36,4 +44,65 @@ public ResponseEntity> getCompanies() { CompanyListResponse response = postQueryService.getCompaniesV2(); return BaseResponse.of(SuccessCode.OK, response); } + + @Operation( + summary = "기업별 게시글 조회 (V2)", + description = """ + 여러 기업의 게시글을 무한 스크롤 방식으로 조회합니다. + companies 파라미터가 없으면 전체 게시글을 조회합니다. + 초기에는 lastPublishedAt과 lastPostId를 빈 채로 호출하고, + 페이징을 할 땐 lastPublishedAt과 lastPostId를 둘 다 동시에 보내주셔야 합니다. + 페이징 관련 값은 응답으로 반환됩니다. + """ + ) + @GetMapping("/by-company") + public ResponseEntity> getPostsByCompany( + @Parameter(description = "회사명 필터 (선택, 없으면 전체 조회)") + @RequestParam(required = false) List companies, + + @Parameter(description = "마지막 게시글 발행시간 (커서 1, 선택)") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @RequestParam(required = false) LocalDateTime lastPublishedAt, + + @Parameter(description = "마지막 게시글 ID (커서 2, 선택)") + @RequestParam(required = false) Long lastPostId, + + @Parameter(description = "페이지 크기 (기본값: 20)") + @RequestParam(defaultValue = "20") int size + ) { + PostListResponse response = postQueryService.getPostsByCompanyV2(companies, lastPublishedAt, lastPostId, size); + return BaseResponse.of(SuccessCode.OK, response); + } + + @Operation( + summary = "최근 게시글 조회 (V2)", + description = """ + 최근 생성된 게시글을 무한 스크롤 방식으로 조회합니다. + sortBy로 정렬 기준을 선택할 수 있습니다. + - LATEST: publishedAt 기준 정렬, lastPublishedAt과 lastPostId 필요 + - POPULAR: viewCount 기준 정렬, lastViewCount와 lastPostId 필요 + 초기 요청 시에는 커서 파라미터를 비워두고, 페이징 시 응답에서 받은 값을 모두 전달해주셔야 합니다. + """ + ) + @GetMapping("/recent") + public ResponseEntity> getRecentPosts( + @Parameter(description = "정렬 기준 (LATEST: 최신순, POPULAR: 인기순, 기본값: LATEST)") + @RequestParam(defaultValue = "LATEST") EPostSortType sortBy, + + @Parameter(description = "마지막 게시글 조회수 (커서, POPULAR 정렬 시 필요)") + @RequestParam(required = false) Integer lastViewCount, + + @Parameter(description = "마지막 게시글 발행시간 (커서, LATEST 정렬 시 필요)") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @RequestParam(required = false) LocalDateTime lastPublishedAt, + + @Parameter(description = "마지막 게시글 ID (커서, 선택)") + @RequestParam(required = false) Long lastPostId, + + @Parameter(description = "페이지 크기 (기본값: 20)") + @RequestParam(defaultValue = "20") int size + ) { + PostListResponse response = postQueryService.getRecentPostsV2(sortBy, lastViewCount, lastPublishedAt, lastPostId, size); + return BaseResponse.of(SuccessCode.OK, response); + } } diff --git a/src/main/java/com/techfork/domain/post/converter/PostConverter.java b/src/main/java/com/techfork/domain/post/converter/PostConverter.java index efcff79..47baa35 100644 --- a/src/main/java/com/techfork/domain/post/converter/PostConverter.java +++ b/src/main/java/com/techfork/domain/post/converter/PostConverter.java @@ -3,6 +3,7 @@ import com.techfork.domain.post.dto.*; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.List; @Component @@ -25,11 +26,22 @@ public PostListResponse toPostListResponse(List posts, int requeste boolean hasNext = posts.size() > requestedSize; List content = hasNext ? posts.subList(0, requestedSize) : posts; - Long lastPostId = content.isEmpty() ? null : content.get(content.size() - 1).id(); + Long lastPostId = null; + Long lastViewCount = null; + LocalDateTime lastPublishedAt = null; + + if (!content.isEmpty()) { + PostInfoDto lastPost = content.get(content.size() - 1); + lastPostId = lastPost.id(); + lastViewCount = lastPost.viewCount(); + lastPublishedAt = lastPost.publishedAt(); + } return PostListResponse.builder() .posts(content) .lastPostId(lastPostId) + .lastViewCount(lastViewCount) + .lastPublishedAt(lastPublishedAt) .hasNext(hasNext) .build(); } diff --git a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java index ed4aef3..431d308 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java +++ b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; +import java.time.LocalDateTime; import java.util.List; @Builder @@ -10,6 +11,8 @@ public record PostListResponse( List posts, Long lastPostId, + Long lastViewCount, + LocalDateTime lastPublishedAt, boolean hasNext ) { } diff --git a/src/main/java/com/techfork/domain/post/entity/Post.java b/src/main/java/com/techfork/domain/post/entity/Post.java index 8e51a9d..e94380f 100644 --- a/src/main/java/com/techfork/domain/post/entity/Post.java +++ b/src/main/java/com/techfork/domain/post/entity/Post.java @@ -17,9 +17,9 @@ @Entity @Table(name = "posts", indexes = { - @Index(name = "idx_post_published_at", columnList = "publishedAt"), - @Index(name = "idx_post_view_count_id", columnList = "viewCount, publishedAt"), - @Index(name = "idx_post_company_published_at", columnList = "company, publishedAt") + @Index(name = "idx_post_published_at_id", columnList = "publishedAt, id"), + @Index(name = "idx_post_view_count_id", columnList = "viewCount, id"), + @Index(name = "idx_post_company_published_at_id", columnList = "company, publishedAt, id") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/techfork/domain/post/repository/PostRepository.java b/src/main/java/com/techfork/domain/post/repository/PostRepository.java index 9d4cad2..e59ec22 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -5,6 +5,7 @@ import com.techfork.domain.post.dto.PostInfoDto; import com.techfork.domain.post.entity.Post; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -62,6 +63,20 @@ List findByCompanyWithCursor( Pageable pageable ); + @Query(""" + SELECT new com.techfork.domain.post.dto.PostInfoDto( + p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) + FROM Post p + WHERE (:companies IS NULL OR p.company IN :companies) + AND ( + :lastPublishedAt IS NULL OR + p.publishedAt < :lastPublishedAt OR + (p.publishedAt = :lastPublishedAt AND p.id < :lastPostId) + ) + ORDER BY p.publishedAt DESC, p.id DESC + """) + List findByCompanyNamesWithCursor(List companies, LocalDateTime lastPublishedAt, Long lastPostId, PageRequest pageRequest); + @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) @@ -74,6 +89,23 @@ List findRecentPostsWithCursor( Pageable pageable ); + @Query(""" + SELECT new com.techfork.domain.post.dto.PostInfoDto( + p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) + FROM Post p + WHERE ( + :lastPublishedAt IS NULL OR + p.publishedAt < :lastPublishedAt OR + (p.publishedAt = :lastPublishedAt AND p.id < :lastPostId) + ) + ORDER BY p.publishedAt DESC, p.id DESC + """) + List findRecentPostsWithCursorV2( + @Param("lastPublishedAt") LocalDateTime lastPublishedAt, + @Param("lastPostId") Long lastPostId, + Pageable pageable + ); + @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) @@ -86,6 +118,23 @@ List findPopularPostsWithCursor( Pageable pageable ); + @Query(""" + SELECT new com.techfork.domain.post.dto.PostInfoDto( + p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) + FROM Post p + WHERE ( + :lastViewCount IS NULL OR + p.viewCount < :lastViewCount OR + (p.viewCount = :lastViewCount AND p.id < :lastPostId) + ) + ORDER BY p.viewCount DESC, p.id DESC + """) + List findPopularPostsWithCursorV2( + @Param("lastViewCount") Integer lastViewCount, + @Param("lastPostId") Long lastPostId, + Pageable pageable + ); + @Query(""" SELECT new com.techfork.domain.post.dto.PostDetailDto( p.id, p.title, p.summary, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null) diff --git a/src/main/java/com/techfork/domain/post/service/PostQueryService.java b/src/main/java/com/techfork/domain/post/service/PostQueryService.java index 4c34ec6..892d97d 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -45,6 +46,13 @@ public PostListResponse getPostsByCompany(String company, Long lastPostId, int s return postConverter.toPostListResponse(postsWithKeywords, size); } + public PostListResponse getPostsByCompanyV2(List companies, LocalDateTime lastPublishedAt, Long lastPostId, int size) { + PageRequest pageRequest = PageRequest.of(0, size + 1); + List posts = postRepository.findByCompanyNamesWithCursor(companies, lastPublishedAt, lastPostId, pageRequest); + List postsWithKeywords = attachKeywordsToPostInfoList(posts); + return postConverter.toPostListResponse(postsWithKeywords, size); + } + public PostListResponse getRecentPosts(EPostSortType sortBy, Long lastPostId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); List posts; @@ -59,6 +67,20 @@ public PostListResponse getRecentPosts(EPostSortType sortBy, Long lastPostId, in return postConverter.toPostListResponse(postsWithKeywords, size); } + public PostListResponse getRecentPostsV2(EPostSortType sortBy, Integer lastViewCount, LocalDateTime lastPublishedAt, Long lastPostId, int size) { + PageRequest pageRequest = PageRequest.of(0, size + 1); + List posts; + + if (sortBy == EPostSortType.POPULAR) { + posts = postRepository.findPopularPostsWithCursorV2(lastViewCount, lastPostId, pageRequest); + } else { + posts = postRepository.findRecentPostsWithCursorV2(lastPublishedAt, lastPostId, pageRequest); + } + + List postsWithKeywords = attachKeywordsToPostInfoList(posts); + return postConverter.toPostListResponse(postsWithKeywords, size); + } + public PostDetailDto getPostDetail(Long postId) { PostDetailDto postDetail = postRepository.findByIdWithTechBlog(postId) .orElseThrow(() -> new GeneralException(CommonErrorCode.NOT_FOUND)); diff --git a/src/test/java/com/techfork/domain/post/controller/PostControllerV2IntegrationTest.java b/src/test/java/com/techfork/domain/post/controller/PostControllerV2IntegrationTest.java index 389fa48..9a83419 100644 --- a/src/test/java/com/techfork/domain/post/controller/PostControllerV2IntegrationTest.java +++ b/src/test/java/com/techfork/domain/post/controller/PostControllerV2IntegrationTest.java @@ -18,6 +18,7 @@ import java.time.LocalDate; 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.result.MockMvcResultHandlers.print; @@ -165,4 +166,251 @@ void getCompanies_SortedByLatestPublishedAt() throws Exception { .andExpect(jsonPath("$.data.companies[0].company").value("네이버")) .andExpect(jsonPath("$.data.companies[1].company").value("카카오")); } + + @Test + @DisplayName("GET /api/v2/posts/by-company - 여러 회사의 게시글 조회 성공") + void getPostsByCompany_MultipleCompanies_Success() throws Exception { + mockMvc.perform(get("/api/v2/posts/by-company") + .param("companies", "카카오", "네이버") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(2)) + .andExpect(jsonPath("$.data.posts[0].company").value("카카오")) + .andExpect(jsonPath("$.data.posts[1].company").value("네이버")) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.lastPostId").exists()) + .andExpect(jsonPath("$.data.lastPublishedAt").exists()); + } + + @Test + @DisplayName("GET /api/v2/posts/by-company - companies 없으면 전체 조회") + void getPostsByCompany_NoCompaniesParam_ReturnsAll() throws Exception { + mockMvc.perform(get("/api/v2/posts/by-company") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(2)); + } + + @Test + @DisplayName("GET /api/v2/posts/by-company - 단일 회사만 조회") + void getPostsByCompany_SingleCompany_Success() throws Exception { + mockMvc.perform(get("/api/v2/posts/by-company") + .param("companies", "카카오") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(1)) + .andExpect(jsonPath("$.data.posts[0].company").value("카카오")) + .andExpect(jsonPath("$.data.hasNext").value(false)); + } + + @Test + @DisplayName("GET /api/v2/posts/by-company - 커서 페이징") + void getPostsByCompany_CursorPaging_Success() throws Exception { + // Given: 추가 게시글 생성 + for (int i = 1; i <= 5; i++) { + Post post = Post.builder() + .title("카카오 게시글 " + i) + .fullContent("

내용 " + i + "

") + .plainContent("내용 " + i) + .company("카카오") + .url("https://kakao.com/post/" + i) + .logoUrl("https://kakao.com/logo.png") + .publishedAt(LocalDateTime.now().minusHours(i)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog1) + .build(); + postRepository.save(post); + } + + // When: 첫 페이지 조회 (size=3) + mockMvc.perform(get("/api/v2/posts/by-company") + .param("companies", "카카오") + .param("size", "3")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts.length()").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(true)); + } + + @Test + @DisplayName("GET /api/v2/posts/by-company - 존재하지 않는 회사 조회 시 빈 배열") + void getPostsByCompany_NonExistentCompany_ReturnsEmpty() throws Exception { + mockMvc.perform(get("/api/v2/posts/by-company") + .param("companies", "존재하지않는회사") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)); + } + + @Test + @DisplayName("GET /api/v2/posts/by-company - 발행일 기준 정렬 확인") + void getPostsByCompany_SortedByPublishedAt() throws Exception { + // Given: 여러 시간대의 게시글 추가 + Post recentPost = Post.builder() + .title("최신 게시글") + .fullContent("

최신

") + .plainContent("최신") + .company("카카오") + .url("https://kakao.com/post/recent") + .logoUrl("https://kakao.com/logo.png") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog1) + .build(); + postRepository.save(recentPost); + + // When & Then: 최신 게시글이 먼저 오는지 확인 + mockMvc.perform(get("/api/v2/posts/by-company") + .param("companies", "카카오") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts[0].title").value("최신 게시글")) + .andExpect(jsonPath("$.data.posts[1].title").value("오늘의 게시글")); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - LATEST 정렬로 최근 게시글 조회") + void getRecentPosts_Latest_Success() throws Exception { + mockMvc.perform(get("/api/v2/posts/recent") + .param("sortBy", "LATEST") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(2)) + .andExpect(jsonPath("$.data.posts[0].title").value("오늘의 게시글")) + .andExpect(jsonPath("$.data.posts[1].title").value("어제의 게시글")) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.lastPostId").exists()) + .andExpect(jsonPath("$.data.lastPublishedAt").exists()); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - POPULAR 정렬로 인기 게시글 조회") + void getRecentPosts_Popular_Success() throws Exception { + // Given: 조회수 증가 + for (int i = 0; i < 100; i++) { + todayPost.incrementViewCount(); + } + for (int i = 0; i < 50; i++) { + oldPost.incrementViewCount(); + } + postRepository.saveAll(List.of(todayPost, oldPost)); + + // When & Then: 조회수 높은 순으로 정렬 + mockMvc.perform(get("/api/v2/posts/recent") + .param("sortBy", "POPULAR") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(2)) + .andExpect(jsonPath("$.data.posts[0].title").value("오늘의 게시글")) + .andExpect(jsonPath("$.data.posts[1].title").value("어제의 게시글")) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.lastPostId").exists()) + .andExpect(jsonPath("$.data.lastViewCount").exists()); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - LATEST 커서 페이징") + void getRecentPosts_Latest_CursorPaging() throws Exception { + // Given: 추가 게시글 생성 + for (int i = 1; i <= 5; i++) { + Post post = Post.builder() + .title("게시글 " + i) + .fullContent("

내용 " + i + "

") + .plainContent("내용 " + i) + .company("카카오") + .url("https://kakao.com/post/" + i) + .logoUrl("https://kakao.com/logo.png") + .publishedAt(LocalDateTime.now().minusHours(i + 1)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog1) + .build(); + postRepository.save(post); + } + + // When: 첫 페이지 조회 (size=3) + mockMvc.perform(get("/api/v2/posts/recent") + .param("sortBy", "LATEST") + .param("size", "3")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts.length()").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(true)); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - POPULAR 커서 페이징") + void getRecentPosts_Popular_CursorPaging() throws Exception { + // Given: 다양한 조회수를 가진 게시글 생성 + for (int i = 1; i <= 5; i++) { + Post post = Post.builder() + .title("인기 게시글 " + i) + .fullContent("

내용 " + i + "

") + .plainContent("내용 " + i) + .company("카카오") + .url("https://kakao.com/post/popular/" + i) + .logoUrl("https://kakao.com/logo.png") + .publishedAt(LocalDateTime.now().minusHours(i)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog1) + .build(); + + // 조회수 설정 + for (int j = 0; j < (6 - i) * 100; j++) { + post.incrementViewCount(); + } + postRepository.save(post); + } + + // When: 첫 페이지 조회 (size=3) + mockMvc.perform(get("/api/v2/posts/recent") + .param("sortBy", "POPULAR") + .param("size", "3")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts.length()").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.lastViewCount").exists()); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - 기본값은 LATEST 정렬") + void getRecentPosts_DefaultSortByLatest() throws Exception { + mockMvc.perform(get("/api/v2/posts/recent") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts[0].title").value("오늘의 게시글")); + } + + @Test + @DisplayName("GET /api/v2/posts/recent - 게시글이 없으면 빈 배열 반환") + void getRecentPosts_EmptyWhenNoPosts() throws Exception { + // Given: 모든 게시글 삭제 + postRepository.deleteAll(); + + // When & Then: 빈 배열 반환 + mockMvc.perform(get("/api/v2/posts/recent") + .param("sortBy", "LATEST") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)); + } } \ No newline at end of file diff --git a/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java index 4701679..c0921d3 100644 --- a/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java +++ b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -39,6 +40,7 @@ class PostRepositoryTest { private TechBlog techBlog1; private TechBlog techBlog2; + private TechBlog techBlog3; @BeforeEach void setUp() { @@ -56,6 +58,13 @@ void setUp() { .rssUrl("https://naver.com/rss") .build(); techBlogRepository.save(techBlog2); + + techBlog3 = TechBlog.builder() + .companyName("AWS") + .blogUrl("https://aws.com/blog") + .rssUrl("https://aws.com/rss") + .build(); + techBlogRepository.save(techBlog3); } @Test @@ -154,6 +163,114 @@ void findByCompanyWithCursor_SpecificCompany_ReturnsFiltered() { assertThat(result).allMatch(dto -> dto.company().equals("카카오")); } + @Test + @DisplayName("companies가 null이면 모든 회사의 게시글을 조회") + void findByCompanyNames_NullCompanies_ReturnsAll() { + // Given + Post kakaoPost = createPost("카카오 게시글", techBlog1, LocalDateTime.now().minusDays(2), 100L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 300L); + Post awsPost = createPost("AWS 게시글", techBlog3, LocalDateTime.now(), 500L); + postRepository.saveAll(List.of(kakaoPost, naverPost, awsPost)); + + // When + List result = postRepository.findByCompanyNamesWithCursor(null, null, null, PageRequest.of(0, 10)); + + // Then + assertThat(result).hasSize(3); + assertThat(result).extracting(PostInfoDto::company) + .containsExactlyInAnyOrder("카카오", "네이버", "AWS"); + } + + @Test + @DisplayName("findByCompanyNamesWithCursor - companies 지정 시 해당 회사들 게시글만 조회") + void findByCompanyNamesWithCursor_SpecificCompanies_ReturnsFiltered() { + // Given: 다른 회사의 게시글 4개 + Post kakaoPost1 = createPost("카카오 게시글1", techBlog1, LocalDateTime.now().minusDays(2), 100L); + Post kakaoPost2 = createPost("카카오 게시글2", techBlog1, LocalDateTime.now().minusDays(1), 200L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 300L); + Post awsPost = createPost("AWS 게시글", techBlog3, LocalDateTime.now(), 500L); + postRepository.saveAll(List.of(kakaoPost1, kakaoPost2, naverPost, awsPost)); + + // When: companies = {"카카오", "네이버"} + PageRequest pageRequest = PageRequest.of(0, 10); + List companyNames = List.of("카카오", "네이버"); + List result = postRepository.findByCompanyNamesWithCursor(companyNames, null, null,pageRequest); + + // Then: 카카오와 네이버 게시글만 반환 + assertThat(result).hasSize(3); + assertThat(result).extracting(PostInfoDto::company) + .containsOnly("카카오", "네이버") + .doesNotContain("AWS"); + } + + @Test + @DisplayName("발행일 내림차순으로 정렬되는지 확인") + void findByCompanyNames_SortPublishedAtCheck() { + // Given: 발행시각 다른 게시글 생성 + Post recentPost = createPost("최신 글", techBlog1, LocalDateTime.now(), 100L); + Post oldPost = createPost("옛날 글", techBlog1, LocalDateTime.now().minusDays(5), 200L); + postRepository.saveAll(List.of(recentPost, oldPost)); + + // When + List result = postRepository.findByCompanyNamesWithCursor(null, null, null, PageRequest.of(0, 10)); + + // Then + assertThat(result) + .extracting(PostInfoDto::title) + .containsExactly("최신 글", "옛날 글"); + } + + @Test + @DisplayName("발행일이 같을 경우 ID 내림차순으로 정렬되는지 확인") + void findByCompanyNames_SortPublihedAtEqualsCheck() { + // Given: 발행시각이 같은 게시글 생성 + LocalDateTime now = LocalDateTime.now(); + Post kakaoPost = createPost("카카오 게시글", techBlog1, now, 100L); + Post naverPost = createPost("네이버 게시글", techBlog2, now, 300L); + Post awsPost = createPost("AWS 게시글", techBlog3, now, 500L); + postRepository.saveAll(List.of(kakaoPost, naverPost, awsPost)); + + // When + List result = postRepository.findByCompanyNamesWithCursor(null, null, null, PageRequest.of(0, 10)); + + // Then: insert 쿼리의 역순 + assertThat(result) + .extracting("id") + .containsExactly(awsPost.getId(), naverPost.getId(), kakaoPost.getId()); + } + + @Test + @DisplayName("커서 기반 페이징 - 1페이지 조회 후 커서 이용해 2페이지 조회") + void findByCompanyNames_CursorPaging() { + // Given + LocalDateTime now = LocalDateTime.now(); + List posts = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + posts.add(createPost("게시글" + i, techBlog1, now.plusHours(i), 10L)); + } + postRepository.saveAll(posts); + + PageRequest pageRequest = PageRequest.of(0, 2); + + // When + List page1 = postRepository.findByCompanyNamesWithCursor( + null, null, null, pageRequest + ); + + PostInfoDto lastPostOfPage1 = page1.get(1); + + List page2 = postRepository.findByCompanyNamesWithCursor( + null, lastPostOfPage1.publishedAt(), lastPostOfPage1.id(), pageRequest + ); + + // Then + assertThat(page1).extracting("title") + .containsExactly("게시글5", "게시글4"); + + assertThat(page2).extracting("title") + .containsExactly("게시글3", "게시글2"); + } + @Test @DisplayName("findByIdWithTechBlog - JOIN하여 게시글 상세 정보 조회 성공") void findByIdWithTechBlog_Success_ReturnsPostDetailDto() { @@ -300,6 +417,118 @@ void cursorPaging_SizePlusOne_CanDetermineHasNext() { assertThat(hasNext).isTrue(); } + @Test + @DisplayName("findRecentPostsWithCursorV2 - publishedAt과 id로 커서 페이징") + void findRecentPostsWithCursorV2_CursorPagingWithPublishedAtAndId() { + // Given: 같은 publishedAt을 가진 게시글 3개 + LocalDateTime now = LocalDateTime.now(); + Post post1 = createPost("게시글1", techBlog1, now, 100L); + Post post2 = createPost("게시글2", techBlog1, now, 200L); + Post post3 = createPost("게시글3", techBlog1, now, 300L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: 첫 페이지 조회 + PageRequest pageRequest = PageRequest.of(0, 10); + List page1 = postRepository.findRecentPostsWithCursorV2(null, null, pageRequest); + + // Then: publishedAt 같으면 id 내림차순으로 정렬 + assertThat(page1).hasSize(3); + assertThat(page1.get(0).id()).isGreaterThan(page1.get(1).id()); + assertThat(page1.get(1).id()).isGreaterThan(page1.get(2).id()); + } + + @Test + @DisplayName("findRecentPostsWithCursorV2 - 커서 기반 다음 페이지 조회") + void findRecentPostsWithCursorV2_NextPageWithCursor() { + // Given: 발행일이 다른 게시글 5개 + LocalDateTime now = LocalDateTime.now(); + Post post1 = createPost("게시글1", techBlog1, now.minusDays(1), 100L); + Post post2 = createPost("게시글2", techBlog1, now.minusDays(2), 200L); + Post post3 = createPost("게시글3", techBlog1, now.minusDays(3), 300L); + Post post4 = createPost("게시글4", techBlog1, now.minusDays(4), 400L); + Post post5 = createPost("게시글5", techBlog1, now.minusDays(5), 500L); + postRepository.saveAll(List.of(post1, post2, post3, post4, post5)); + + // When: 첫 페이지 조회 후 두 번째 페이지 조회 + PageRequest pageRequest = PageRequest.of(0, 2); + List page1 = postRepository.findRecentPostsWithCursorV2(null, null, pageRequest); + + PostInfoDto lastPost = page1.get(1); + List page2 = postRepository.findRecentPostsWithCursorV2( + lastPost.publishedAt(), lastPost.id(), pageRequest + ); + + // Then: 커서 이후 게시글만 반환 + assertThat(page1).hasSize(2); + assertThat(page2).hasSize(2); + assertThat(page2.get(0).publishedAt()).isBefore(lastPost.publishedAt()); + } + + @Test + @DisplayName("findPopularPostsWithCursorV2 - viewCount와 id로 커서 페이징") + void findPopularPostsWithCursorV2_CursorPagingWithViewCountAndId() { + // Given: 조회수가 다른 게시글 3개 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(1), 500L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(2), 300L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(3), 100L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: 첫 페이지 조회 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursorV2(null, null, pageRequest); + + // Then: viewCount 내림차순으로 정렬 (500 > 300 > 100) + assertThat(result).hasSize(3); + assertThat(result.get(0).viewCount()).isEqualTo(500L); + assertThat(result.get(1).viewCount()).isEqualTo(300L); + assertThat(result.get(2).viewCount()).isEqualTo(100L); + } + + @Test + @DisplayName("findPopularPostsWithCursorV2 - 같은 viewCount일 때 id로 정렬") + void findPopularPostsWithCursorV2_SameViewCount_OrderById() { + // Given: 같은 조회수를 가진 게시글 3개 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(1), 500L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(2), 500L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(3), 500L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: 조회 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursorV2(null, null, pageRequest); + + // Then: viewCount 같으면 id 내림차순 + assertThat(result).hasSize(3); + assertThat(result.get(0).id()).isGreaterThan(result.get(1).id()); + assertThat(result.get(1).id()).isGreaterThan(result.get(2).id()); + } + + @Test + @DisplayName("findPopularPostsWithCursorV2 - 커서 기반 다음 페이지 조회") + void findPopularPostsWithCursorV2_NextPageWithCursor() { + // Given: 조회수가 다른 게시글 5개 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(1), 500L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(2), 400L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(3), 300L); + Post post4 = createPost("게시글4", techBlog1, LocalDateTime.now().minusDays(4), 200L); + Post post5 = createPost("게시글5", techBlog1, LocalDateTime.now().minusDays(5), 100L); + postRepository.saveAll(List.of(post1, post2, post3, post4, post5)); + + // When: 첫 페이지 조회 후 두 번째 페이지 조회 + PageRequest pageRequest = PageRequest.of(0, 2); + List page1 = postRepository.findPopularPostsWithCursorV2(null, null, pageRequest); + + PostInfoDto lastPost = page1.get(1); + List page2 = postRepository.findPopularPostsWithCursorV2( + lastPost.viewCount().intValue(), lastPost.id(), pageRequest + ); + + // Then: 커서 이후 게시글만 반환 + assertThat(page1).hasSize(2); + assertThat(page2).hasSize(2); + assertThat(page2.get(0).viewCount()).isLessThanOrEqualTo(lastPost.viewCount()); + } + // 헬퍼 메서드 private Post createPost(String title, TechBlog techBlog, LocalDateTime publishedAt, Long viewCount) { Post post = Post.builder() diff --git a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java index aadaf9b..233243a 100644 --- a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java @@ -337,4 +337,373 @@ void getPostsByCompany_Success() { verify(postRepository, times(1)).findByCompanyWithCursor(eq(company), eq(lastPostId), any(PageRequest.class)); } + + @Test + @DisplayName("getPostsByCompanyV2() - 여러 회사의 게시글 조회 성공") + void getPostsByCompanyV2_Success() { + // Given + List companies = List.of("카카오", "네이버"); + LocalDateTime lastPublishedAt = null; + Long lastPostId = null; + int size = 20; + + LocalDateTime now = LocalDateTime.now(); + List mockPosts = List.of( + PostInfoDto.builder() + .id(2L) + .title("네이버 게시글") + .company("네이버") + .url("https://test.com/2") + .logoUrl("https://test.com/naver-logo.png") + .publishedAt(now) + .viewCount(100L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(1L) + .title("카카오 게시글") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/kakao-logo.png") + .publishedAt(now.minusHours(1)) + .viewCount(50L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .lastPublishedAt(now.minusHours(1)) + .hasNext(false) + .build(); + + given(postRepository.findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getPostsByCompanyV2(companies, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.posts().get(0).company()).isEqualTo("네이버"); + assertThat(result.posts().get(1).company()).isEqualTo("카카오"); + assertThat(result.posts().get(0).publishedAt()).isAfter(result.posts().get(1).publishedAt()); + + verify(postRepository, times(1)).findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class)); + verify(postKeywordRepository, times(1)).findByPostIdIn(any()); + verify(postConverter, times(1)).toPostListResponse(any(), eq(size)); + } + + @Test + @DisplayName("getPostsByCompanyV2() - companies가 null이면 전체 게시글 조회") + void getPostsByCompanyV2_NullCompanies_ReturnsAll() { + // Given + List companies = null; + LocalDateTime lastPublishedAt = null; + Long lastPostId = null; + int size = 20; + + LocalDateTime now = LocalDateTime.now(); + List mockPosts = List.of( + PostInfoDto.builder() + .id(3L) + .title("라인 게시글") + .company("라인") + .url("https://test.com/3") + .logoUrl("https://test.com/line-logo.png") + .publishedAt(now) + .viewCount(200L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(2L) + .title("네이버 게시글") + .company("네이버") + .url("https://test.com/2") + .logoUrl("https://test.com/naver-logo.png") + .publishedAt(now.minusHours(1)) + .viewCount(100L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(2L) + .lastPublishedAt(now.minusHours(1)) + .hasNext(false) + .build(); + + given(postRepository.findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getPostsByCompanyV2(companies, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + + verify(postRepository, times(1)).findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class)); + } + + @Test + @DisplayName("getPostsByCompanyV2() - 커서 페이징으로 다음 페이지 조회") + void getPostsByCompanyV2_WithCursor_ReturnsNextPage() { + // Given + List companies = List.of("카카오"); + LocalDateTime lastPublishedAt = LocalDateTime.now().minusHours(2); + Long lastPostId = 100L; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(99L) + .title("카카오 게시글 99") + .company("카카오") + .url("https://test.com/99") + .logoUrl("https://test.com/kakao-logo.png") + .publishedAt(lastPublishedAt.minusMinutes(10)) + .viewCount(50L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(99L) + .lastPublishedAt(lastPublishedAt.minusMinutes(10)) + .hasNext(false) + .build(); + + given(postRepository.findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getPostsByCompanyV2(companies, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).id()).isEqualTo(99L); + + verify(postRepository, times(1)).findByCompanyNamesWithCursor(eq(companies), eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class)); + } + + @Test + @DisplayName("getRecentPostsV2() - LATEST 정렬로 최근 게시글 조회") + void getRecentPostsV2_Latest_Success() { + // Given + EPostSortType sortBy = EPostSortType.LATEST; + Integer lastViewCount = null; + LocalDateTime lastPublishedAt = null; + Long lastPostId = null; + int size = 20; + + LocalDateTime now = LocalDateTime.now(); + List mockPosts = List.of( + PostInfoDto.builder() + .id(2L) + .title("게시글 2") + .company("카카오") + .url("https://test.com/2") + .logoUrl("https://test.com/logo.png") + .publishedAt(now) + .viewCount(50L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(1L) + .title("게시글 1") + .company("네이버") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(now.minusDays(1)) + .viewCount(100L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .lastPublishedAt(now.minusDays(1)) + .hasNext(false) + .build(); + + given(postRepository.findRecentPostsWithCursorV2(eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPostsV2(sortBy, lastViewCount, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.posts().get(0).publishedAt()).isAfter(result.posts().get(1).publishedAt()); + assertThat(result.hasNext()).isFalse(); + + verify(postRepository, times(1)).findRecentPostsWithCursorV2(eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findPopularPostsWithCursorV2(any(), any(), any()); + } + + @Test + @DisplayName("getRecentPostsV2() - POPULAR 정렬로 인기 게시글 조회") + void getRecentPostsV2_Popular_Success() { + // Given + EPostSortType sortBy = EPostSortType.POPULAR; + Integer lastViewCount = null; + LocalDateTime lastPublishedAt = null; + Long lastPostId = null; + int size = 20; + + LocalDateTime now = LocalDateTime.now(); + List mockPosts = List.of( + PostInfoDto.builder() + .id(1L) + .title("인기 게시글 1") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(now) + .viewCount(1000L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(2L) + .title("인기 게시글 2") + .company("네이버") + .url("https://test.com/2") + .logoUrl("https://test.com/logo.png") + .publishedAt(now) + .viewCount(500L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(2L) + .lastViewCount(500L) + .hasNext(false) + .build(); + + given(postRepository.findPopularPostsWithCursorV2(eq(lastViewCount), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPostsV2(sortBy, lastViewCount, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.posts().get(0).viewCount()).isGreaterThan(result.posts().get(1).viewCount()); + + verify(postRepository, times(1)).findPopularPostsWithCursorV2(eq(lastViewCount), eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findRecentPostsWithCursorV2(any(), any(), any()); + } + + @Test + @DisplayName("getRecentPostsV2() - POPULAR 정렬 커서 페이징") + void getRecentPostsV2_Popular_WithCursor() { + // Given + EPostSortType sortBy = EPostSortType.POPULAR; + Integer lastViewCount = 500; + LocalDateTime lastPublishedAt = LocalDateTime.now(); + Long lastPostId = 100L; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(99L) + .title("인기 게시글 99") + .company("카카오") + .url("https://test.com/99") + .logoUrl("https://test.com/logo.png") + .publishedAt(lastPublishedAt.minusHours(1)) + .viewCount(400L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(99L) + .lastViewCount(400L) + .hasNext(false) + .build(); + + given(postRepository.findPopularPostsWithCursorV2(eq(lastViewCount), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPostsV2(sortBy, lastViewCount, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).viewCount()).isLessThan(lastViewCount); + + verify(postRepository, times(1)).findPopularPostsWithCursorV2(eq(lastViewCount), eq(lastPostId), any(PageRequest.class)); + } + + @Test + @DisplayName("getRecentPostsV2() - LATEST 정렬 커서 페이징") + void getRecentPostsV2_Latest_WithCursor() { + // Given + EPostSortType sortBy = EPostSortType.LATEST; + Integer lastViewCount = null; + LocalDateTime lastPublishedAt = LocalDateTime.now().minusHours(2); + Long lastPostId = 100L; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(99L) + .title("게시글 99") + .company("카카오") + .url("https://test.com/99") + .logoUrl("https://test.com/logo.png") + .publishedAt(lastPublishedAt.minusHours(1)) + .viewCount(50L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(99L) + .lastPublishedAt(lastPublishedAt.minusHours(1)) + .hasNext(false) + .build(); + + given(postRepository.findRecentPostsWithCursorV2(eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPostsV2(sortBy, lastViewCount, lastPublishedAt, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).publishedAt()).isBefore(lastPublishedAt); + + verify(postRepository, times(1)).findRecentPostsWithCursorV2(eq(lastPublishedAt), eq(lastPostId), any(PageRequest.class)); + } }