diff --git a/build.gradle b/build.gradle index 84bf543..5375948 100644 --- a/build.gradle +++ b/build.gradle @@ -35,10 +35,16 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Testcontainers for integration tests + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:mysql:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.mysql:mysql-connector-j' + testRuntimeOnly 'com.h2database:h2' // 테스트용 in-memory database developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java b/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java index 7515472..8e32c42 100644 --- a/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java +++ b/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java @@ -43,9 +43,9 @@ public void saveReadPost(Long userId, ReadPostRequest request) { Post post = postRepository.findById(request.postId()) .orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND)); - if (readPostRepository.existsByUserAndPost(user, post)) { - log.info("User {} has already read post {}", userId, request.postId()); - return; + boolean isFirstRead = !readPostRepository.existsByUserAndPost(user, post); + if (isFirstRead) { + post.incrementViewCount(); } ReadPost readPost = ReadPost.create( @@ -56,7 +56,8 @@ public void saveReadPost(Long userId, ReadPostRequest request) { ); readPostRepository.save(readPost); - log.info("Saved read post for user {} and post {}", userId, request.postId()); + log.info("Saved read post for user {} and post {} (viewCount incremented: {})", + userId, request.postId(), isFirstRead); } @Transactional 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 52dc1ce..be81b29 100644 --- a/src/main/java/com/techfork/domain/post/converter/PostConverter.java +++ b/src/main/java/com/techfork/domain/post/converter/PostConverter.java @@ -39,6 +39,7 @@ public PostDetailDto toPostDetailDto(PostDetailDto baseDto, List keyword .url(baseDto.url()) .logoUrl(baseDto.logoUrl()) .publishedAt(baseDto.publishedAt()) + .viewCount(baseDto.viewCount()) .keywords(keywords) .build(); } diff --git a/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java b/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java index 7a4fbaf..5848b23 100644 --- a/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java +++ b/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java @@ -1,11 +1,13 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record CompanyListResponse( - List companies -) { -} +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "CompanyListResponse") +public record CompanyListResponse( + List companies +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java index d138e0a..4f54f9f 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java +++ b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java @@ -1,19 +1,22 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -@Builder -public record PostDetailDto( - Long id, - String title, - String summary, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - List keywords -) { -} +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema(name = "PostDetailResponse") +public record PostDetailDto( + Long id, + String title, + String summary, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java index be75186..95f19b0 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java +++ b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java @@ -1,18 +1,21 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -@Builder -public record PostInfoDto( - Long id, - String title, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - List keywords -) { -} +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema(name = "PostInfoDto") +public record PostInfoDto( + Long id, + String title, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords +) { +} 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 99a608a..ed4aef3 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java +++ b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java @@ -1,13 +1,15 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record PostListResponse( - List posts, - Long lastPostId, - boolean hasNext -) { -} +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "PostListResponse") +public record PostListResponse( + List posts, + Long lastPostId, + 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 a2c2e7b..a3a0350 100644 --- a/src/main/java/com/techfork/domain/post/entity/Post.java +++ b/src/main/java/com/techfork/domain/post/entity/Post.java @@ -46,6 +46,9 @@ public class Post extends BaseEntity { @Column private LocalDateTime embeddedAt; + @Column(nullable = false) + private Long viewCount = 0L; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "tech_blog_id", nullable = false) private TechBlog techBlog; @@ -94,4 +97,8 @@ public void addKeyword(PostKeyword keyword) { public void clearKeywords() { this.keywords.clear(); } + + public void incrementViewCount() { + this.viewCount++; + } } 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 7844199..b762d00 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -36,7 +36,7 @@ public interface PostRepository extends JpaRepository { @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE (:company IS NULL OR p.company = :company) @@ -51,7 +51,7 @@ List findByCompanyWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId @@ -64,11 +64,11 @@ List findRecentPostsWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId - ORDER BY p.crawledAt DESC, p.id DESC + ORDER BY p.viewCount DESC, p.id DESC """) List findPopularPostsWithCursor( @Param("lastPostId") Long lastPostId, @@ -77,7 +77,7 @@ List findPopularPostsWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostDetailDto( - p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE p.id = :id 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 e671f25..d9e4387 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -1,7 +1,10 @@ package com.techfork.domain.post.service; import com.techfork.domain.post.converter.PostConverter; -import com.techfork.domain.post.dto.*; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.repository.PostKeywordRepository; @@ -90,6 +93,7 @@ private List attachKeywordsToPostInfoList(List posts) .url(post.url()) .logoUrl(post.logoUrl()) .publishedAt(post.publishedAt()) + .viewCount(post.viewCount()) .keywords(keywordMap.getOrDefault(post.id(), List.of())) .build()) .toList(); diff --git a/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java b/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java new file mode 100644 index 0000000..566f5e5 --- /dev/null +++ b/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java @@ -0,0 +1,144 @@ +package com.techfork.domain.activity.service; + +import com.techfork.domain.activity.dto.ReadPostRequest; +import com.techfork.domain.activity.entity.ReadPost; +import com.techfork.domain.activity.repository.ReadPostRepository; +import com.techfork.domain.activity.repository.ScrabPostRepository; +import com.techfork.domain.activity.repository.SearchHistoryRepository; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.repository.UserRepository; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * ActivityCommandService 단위 테스트 + * - Mockito를 사용해서 의존성을 Mock으로 대체 + * - 실제 DB 없이 빠르게 테스트 + */ +@ExtendWith(MockitoExtension.class) +class ActivityCommandServiceTest { + + @Mock + private ReadPostRepository readPostRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ActivityCommandService activityCommandService; + + @Test + @DisplayName("처음 읽는 게시글이면 조회수가 증가한다") + void saveReadPost_FirstRead_IncrementViewCount() { + // Given: 테스트 데이터 준비 + Long userId = 1L; + Long postId = 100L; + + User mockUser = mock(User.class); + TechBlog mockTechBlog = TechBlog.builder() + .companyName("테스트회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + + Post mockPost = Post.builder() + .title("테스트 제목") + .fullContent("내용") + .plainContent("내용") + .company("테스트회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(mockTechBlog) + .build(); + + ReadPostRequest request = new ReadPostRequest( + postId, + LocalDateTime.now(), + 300 + ); + + // Mock 동작 정의 + given(userRepository.findById(userId)).willReturn(Optional.of(mockUser)); + given(postRepository.findById(postId)).willReturn(Optional.of(mockPost)); + given(readPostRepository.existsByUserAndPost(mockUser, mockPost)).willReturn(false); // 처음 읽음 + given(readPostRepository.save(any(ReadPost.class))).willReturn(mock(ReadPost.class)); + + Long beforeViewCount = mockPost.getViewCount(); + + // When: saveReadPost 호출 + activityCommandService.saveReadPost(userId, request); + + // Then: 조회수가 1 증가했는지 검증 + assertThat(mockPost.getViewCount()).isEqualTo(beforeViewCount + 1); + + // 그리고 ReadPost가 저장되었는지 검증 + verify(readPostRepository, times(1)).save(any(ReadPost.class)); + } + + @Test + @DisplayName("이미 읽은 게시글이면 조회수가 증가하지 않는다") + void saveReadPost_AlreadyRead_NoIncrementViewCount() { + // Given + Long userId = 1L; + Long postId = 100L; + + User mockUser = mock(User.class); + TechBlog mockTechBlog = TechBlog.builder() + .companyName("테스트회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + + Post mockPost = Post.builder() + .title("테스트 제목") + .fullContent("내용") + .plainContent("내용") + .company("테스트회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(mockTechBlog) + .build(); + + ReadPostRequest request = new ReadPostRequest( + postId, + LocalDateTime.now(), + 300 + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(mockUser)); + given(postRepository.findById(postId)).willReturn(Optional.of(mockPost)); + given(readPostRepository.existsByUserAndPost(mockUser, mockPost)).willReturn(true); // 이미 읽음 + given(readPostRepository.save(any(ReadPost.class))).willReturn(mock(ReadPost.class)); + + Long beforeViewCount = mockPost.getViewCount(); + + // When + activityCommandService.saveReadPost(userId, request); + + // Then: 조회수가 증가하지 않음 + assertThat(mockPost.getViewCount()).isEqualTo(beforeViewCount); + + // 하지만 ReadPost는 저장됨 (읽은 기록은 매번 저장) + verify(readPostRepository, times(1)).save(any(ReadPost.class)); + } +} diff --git a/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java b/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java new file mode 100644 index 0000000..12d02dd --- /dev/null +++ b/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java @@ -0,0 +1,164 @@ +package com.techfork.domain.post.controller; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.repository.PostKeywordRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +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; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * PostController 통합 테스트 + * - @SpringBootTest: 전체 애플리케이션 컨텍스트 로드 + * - Testcontainers: 실제 MySQL 컨테이너로 통합 테스트 + * - 모든 레이어(Controller, Service, Repository) 통합 테스트 + * - MockMvc로 HTTP 요청/응답 테스트 + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Testcontainers +class PostControllerIntegrationTest { + + @Container + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl); + registry.add("spring.datasource.username", mysql::getUsername); + registry.add("spring.datasource.password", mysql::getPassword); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostKeywordRepository postKeywordRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + private TechBlog testTechBlog; + private Post testPost1; + private Post testPost2; + + @BeforeEach + void setUp() { + // Given: 실제 DB에 테스트 데이터 저장 + testTechBlog = TechBlog.builder() + .companyName("테스트 회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + techBlogRepository.save(testTechBlog); + + testPost1 = Post.builder() + .title("테스트 게시글 1") + .fullContent("

전체 내용 1

") + .plainContent("전체 내용 1") + .company("테스트 회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.of(2025, 1, 1, 10, 0)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + testPost1 = postRepository.save(testPost1); + + // 키워드 추가 + PostKeyword keyword1 = PostKeyword.create("Java", testPost1); + PostKeyword keyword2 = PostKeyword.create("Spring", testPost1); + postKeywordRepository.saveAll(List.of(keyword1, keyword2)); + + testPost2 = Post.builder() + .title("테스트 게시글 2") + .fullContent("

전체 내용 2

") + .plainContent("전체 내용 2") + .company("테스트 회사") + .url("https://test.com/post/2") + .publishedAt(LocalDateTime.of(2025, 1, 2, 10, 0)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + testPost2 = postRepository.save(testPost2); + + PostKeyword keyword3 = PostKeyword.create("Kotlin", testPost2); + postKeywordRepository.save(keyword3); + } + + @Test + @DisplayName("GET /api/v1/posts/{postId} - 게시글 상세 조회 성공") + void getPostDetail_Success() throws Exception { + // When & Then: 실제 DB에 저장된 데이터로 HTTP 요청 테스트 + mockMvc.perform(get("/api/v1/posts/{postId}", testPost1.getId())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(testPost1.getId())) + .andExpect(jsonPath("$.data.title").value("테스트 게시글 1")) + .andExpect(jsonPath("$.data.company").value("테스트 회사")) + .andExpect(jsonPath("$.data.keywords").isArray()) + .andExpect(jsonPath("$.data.keywords.length()").value(2)); + } + + @Test + @DisplayName("GET /api/v1/posts/recent - 최근 게시글 조회 성공") + void getRecentPosts_Success() throws Exception { + // When & Then: 실제 DB에서 최근 게시글 조회 + mockMvc.perform(get("/api/v1/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.hasNext").value(false)); + } + + @Test + @DisplayName("GET /api/v1/posts/companies - 회사 목록 조회 성공") + void getCompanies_Success() throws Exception { + // When & Then: 실제 DB에서 회사 목록 조회 + mockMvc.perform(get("/api/v1/posts/companies")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies").isArray()) + .andExpect(jsonPath("$.data.companies[0]").value("테스트 회사")) + .andExpect(jsonPath("$.data.companies.length()").value(1)); + } + + @Test + @DisplayName("GET /api/v1/posts/{postId} - 존재하지 않는 게시글 조회 시 404") + void getPostDetail_NotFound() throws Exception { + // When & Then: 존재하지 않는 게시글 조회 + mockMvc.perform(get("/api/v1/posts/{postId}", 99999L)) + .andDo(print()) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java new file mode 100644 index 0000000..37fb605 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java @@ -0,0 +1,241 @@ +package com.techfork.domain.post.repository; + +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +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.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PostRepository 테스트 + * - @DataJpaTest: JPA 관련 컴포넌트만 로드 + * - @ActiveProfiles("test"): H2 in-memory database 사용 + * - 복잡한 JPQL 쿼리, 커서 페이징, 프로젝션 검증 + */ +@DataJpaTest +@ActiveProfiles("test") +class PostRepositoryTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + private TechBlog techBlog1; + private TechBlog techBlog2; + + @BeforeEach + void setUp() { + // Given: 테스트용 TechBlog 생성 + techBlog1 = TechBlog.builder() + .companyName("카카오") + .blogUrl("https://kakao.com/blog") + .rssUrl("https://kakao.com/rss") + .build(); + techBlogRepository.save(techBlog1); + + techBlog2 = TechBlog.builder() + .companyName("네이버") + .blogUrl("https://naver.com/blog") + .rssUrl("https://naver.com/rss") + .build(); + techBlogRepository.save(techBlog2); + } + + @Test + @DisplayName("findPopularPostsWithCursor - lastPostId가 null이면 첫 페이지 조회 (조회수 높은 순)") + void findPopularPostsWithCursor_FirstPage_OrderByViewCountDesc() { + // Given: 조회수가 다른 게시글 3개 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(3), 100L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(2), 500L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(1), 300L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: lastPostId = null, size = 10 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursor(null, pageRequest); + + // Then: 조회수 높은 순으로 정렬 (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("findPopularPostsWithCursor - lastPostId 지정 시 해당 ID 이후 게시글만 조회") + void findPopularPostsWithCursor_NextPage_FilterByLastPostId() { + // Given: 게시글 5개 생성 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(5), 500L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(4), 400L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(3), 300L); + Post post4 = createPost("게시글4", techBlog1, LocalDateTime.now().minusDays(2), 200L); + Post post5 = createPost("게시글5", techBlog1, LocalDateTime.now().minusDays(1), 100L); + postRepository.saveAll(List.of(post1, post2, post3, post4, post5)); + + // When: lastPostId = post3.id (커서 기준) + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursor(post3.getId(), pageRequest); + + // Then: post3.id보다 작은 ID만 반환 (post4, post5는 제외) + assertThat(result).hasSize(2); + assertThat(result).allMatch(dto -> dto.id() < post3.getId()); + } + + @Test + @DisplayName("findRecentPostsWithCursor - 최신 발행일 순으로 정렬") + void findRecentPostsWithCursor_OrderByPublishedAtDesc() { + // Given: 발행일이 다른 게시글 3개 + LocalDateTime now = LocalDateTime.now(); + Post post1 = createPost("게시글1", techBlog1, now.minusDays(3), 100L); + Post post2 = createPost("게시글2", techBlog1, now.minusDays(1), 200L); + Post post3 = createPost("게시글3", techBlog1, now.minusDays(2), 300L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: 최신순 조회 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findRecentPostsWithCursor(null, pageRequest); + + // Then: 발행일 최신순 (post2 > post3 > post1) + assertThat(result).hasSize(3); + assertThat(result.get(0).publishedAt()).isAfter(result.get(1).publishedAt()); + assertThat(result.get(1).publishedAt()).isAfter(result.get(2).publishedAt()); + } + + @Test + @DisplayName("findByCompanyWithCursor - company가 null이면 모든 게시글 조회") + void findByCompanyWithCursor_NullCompany_ReturnsAll() { + // Given: 다른 회사의 게시글 2개 + Post kakaoPost = createPost("카카오 게시글", techBlog1, LocalDateTime.now(), 100L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 200L); + postRepository.saveAll(List.of(kakaoPost, naverPost)); + + // When: company = null + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findByCompanyWithCursor(null, null, pageRequest); + + // Then: 모든 게시글 반환 + assertThat(result).hasSize(2); + assertThat(result).extracting(PostInfoDto::company) + .containsExactlyInAnyOrder("카카오", "네이버"); + } + + @Test + @DisplayName("findByCompanyWithCursor - company 지정 시 해당 회사 게시글만 조회") + void findByCompanyWithCursor_SpecificCompany_ReturnsFiltered() { + // Given: 다른 회사의 게시글 3개 + 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); + postRepository.saveAll(List.of(kakaoPost1, kakaoPost2, naverPost)); + + // When: company = "카카오" + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findByCompanyWithCursor("카카오", null, pageRequest); + + // Then: 카카오 게시글만 반환 + assertThat(result).hasSize(2); + assertThat(result).allMatch(dto -> dto.company().equals("카카오")); + } + + @Test + @DisplayName("findByIdWithTechBlog - JOIN하여 게시글 상세 정보 조회 성공") + void findByIdWithTechBlog_Success_ReturnsPostDetailDto() { + // Given: 게시글 생성 + Post post = createPost("테스트 게시글", techBlog1, LocalDateTime.now(), 100L); + post = postRepository.save(post); + + // When: ID로 조회 + Optional result = postRepository.findByIdWithTechBlog(post.getId()); + + // Then: PostDetailDto 프로젝션 성공 + assertThat(result).isPresent(); + PostDetailDto dto = result.get(); + assertThat(dto.id()).isEqualTo(post.getId()); + assertThat(dto.title()).isEqualTo("테스트 게시글"); + assertThat(dto.company()).isEqualTo("카카오"); + assertThat(dto.viewCount()).isEqualTo(100L); + assertThat(dto.keywords()).isNull(); // 키워드는 별도 조회 + } + + @Test + @DisplayName("findByIdWithTechBlog - 존재하지 않는 ID 조회 시 Empty 반환") + void findByIdWithTechBlog_NotFound_ReturnsEmpty() { + // When: 존재하지 않는 ID 조회 + Optional result = postRepository.findByIdWithTechBlog(99999L); + + // Then: Empty 반환 + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findDistinctCompanies - 중복 없이 회사 목록 조회") + void findDistinctCompanies_ReturnsUniqueCompanies() { + // Given: 같은 회사의 게시글 여러 개 + Post kakaoPost1 = createPost("카카오 게시글1", techBlog1, LocalDateTime.now(), 100L); + Post kakaoPost2 = createPost("카카오 게시글2", techBlog1, LocalDateTime.now(), 200L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 300L); + postRepository.saveAll(List.of(kakaoPost1, kakaoPost2, naverPost)); + + // When: 회사 목록 조회 + List result = postRepository.findDistinctCompanies(); + + // Then: 중복 없이 2개 회사 반환 + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder("카카오", "네이버"); + } + + @Test + @DisplayName("커서 페이징 - size+1 조회하여 hasNext 판단 가능") + void cursorPaging_SizePlusOne_CanDetermineHasNext() { + // Given: 게시글 5개 + for (int i = 1; i <= 5; i++) { + Post post = createPost("게시글" + i, techBlog1, LocalDateTime.now().minusDays(i), (long) (i * 100)); + postRepository.save(post); + } + + // When: size = 3으로 조회 (실제로는 4개 조회) + PageRequest pageRequest = PageRequest.of(0, 4); + List result = postRepository.findRecentPostsWithCursor(null, pageRequest); + + // Then: 4개 조회되면 hasNext = true + assertThat(result).hasSize(4); + boolean hasNext = result.size() > 3; + assertThat(hasNext).isTrue(); + } + + // 헬퍼 메서드 + private Post createPost(String title, TechBlog techBlog, LocalDateTime publishedAt, Long viewCount) { + Post post = Post.builder() + .title(title) + .fullContent("

" + title + " 내용

") + .plainContent(title + " 내용") + .company(techBlog.getCompanyName()) + .url("https://test.com/" + title) + .publishedAt(publishedAt) + .crawledAt(LocalDateTime.now()) + .techBlog(techBlog) + .build(); + + // viewCount 설정 (reflection 또는 여러 번 증가) + for (int i = 0; i < viewCount; i++) { + post.incrementViewCount(); + } + + return post; + } +} diff --git a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java new file mode 100644 index 0000000..4f2b7be --- /dev/null +++ b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java @@ -0,0 +1,298 @@ +package com.techfork.domain.post.service; + +import com.techfork.domain.post.converter.PostConverter; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.enums.EPostSortType; +import com.techfork.domain.post.repository.PostKeywordRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.global.exception.GeneralException; +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 org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * PostQueryService 단위 테스트 + * - Repository와 Converter를 Mock으로 대체 + * - 비즈니스 로직만 검증 + */ +@ExtendWith(MockitoExtension.class) +class PostQueryServiceTest { + + @Mock + private PostRepository postRepository; + + @Mock + private PostKeywordRepository postKeywordRepository; + + @Mock + private PostConverter postConverter; + + @InjectMocks + private PostQueryService postQueryService; + + @Test + @DisplayName("getCompanies() - 회사 목록 조회 성공") + void getCompanies_Success() { + // Given + List mockCompanies = List.of("카카오", "네이버", "라인"); + CompanyListResponse expectedResponse = new CompanyListResponse(mockCompanies); + + given(postRepository.findDistinctCompanies()).willReturn(mockCompanies); + given(postConverter.toCompanyListResponse(mockCompanies)).willReturn(expectedResponse); + + // When + CompanyListResponse result = postQueryService.getCompanies(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.companies()).hasSize(3); + assertThat(result.companies()).contains("카카오", "네이버", "라인"); + + verify(postRepository, times(1)).findDistinctCompanies(); + verify(postConverter, times(1)).toCompanyListResponse(mockCompanies); + } + + @Test + @DisplayName("getPostDetail() - 게시글 상세 조회 성공") + void getPostDetail_Success() { + // Given + Long postId = 1L; + + PostDetailDto mockPostDetail = PostDetailDto.builder() + .id(postId) + .title("테스트 제목") + .summary("테스트 요약") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(100L) + .keywords(null) // 키워드는 나중에 추가됨 + .build(); + + PostKeyword keyword1 = mock(PostKeyword.class); + when(keyword1.getKeyword()).thenReturn("Java"); + PostKeyword keyword2 = mock(PostKeyword.class); + when(keyword2.getKeyword()).thenReturn("Spring"); + + List mockKeywords = List.of(keyword1, keyword2); + List keywordStrings = List.of("Java", "Spring"); + + PostDetailDto expectedResponse = PostDetailDto.builder() + .id(postId) + .title("테스트 제목") + .summary("테스트 요약") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(mockPostDetail.publishedAt()) + .viewCount(100L) + .keywords(keywordStrings) + .build(); + + given(postRepository.findByIdWithTechBlog(postId)).willReturn(Optional.of(mockPostDetail)); + given(postKeywordRepository.findByPostIdIn(List.of(postId))).willReturn(mockKeywords); + given(postConverter.toPostDetailDto(mockPostDetail, keywordStrings)).willReturn(expectedResponse); + + // When + PostDetailDto result = postQueryService.getPostDetail(postId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(postId); + assertThat(result.title()).isEqualTo("테스트 제목"); + assertThat(result.viewCount()).isEqualTo(100L); + assertThat(result.keywords()).hasSize(2); + assertThat(result.keywords()).contains("Java", "Spring"); + + verify(postRepository, times(1)).findByIdWithTechBlog(postId); + verify(postKeywordRepository, times(1)).findByPostIdIn(List.of(postId)); + verify(postConverter, times(1)).toPostDetailDto(mockPostDetail, keywordStrings); + } + + @Test + @DisplayName("getPostDetail() - 존재하지 않는 게시글 조회 시 예외 발생") + void getPostDetail_NotFound_ThrowsException() { + // Given + Long postId = 999L; + given(postRepository.findByIdWithTechBlog(postId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> postQueryService.getPostDetail(postId)) + .isInstanceOf(GeneralException.class); + + verify(postRepository, times(1)).findByIdWithTechBlog(postId); + verify(postKeywordRepository, never()).findByPostIdIn(any()); + } + + @Test + @DisplayName("getRecentPosts() - LATEST 정렬로 최근 게시글 조회") + void getRecentPosts_Latest_Success() { + // Given + EPostSortType sortBy = EPostSortType.LATEST; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(2L) + .title("게시글 2") + .company("카카오") + .url("https://test.com/2") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.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(LocalDateTime.now().minusDays(1)) + .viewCount(100L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .hasNext(false) + .build(); + + given(postRepository.findRecentPostsWithCursor(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.getRecentPosts(sortBy, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.hasNext()).isFalse(); + + verify(postRepository, times(1)).findRecentPostsWithCursor(eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findPopularPostsWithCursor(any(), any()); + } + + @Test + @DisplayName("getRecentPosts() - POPULAR 정렬로 인기 게시글 조회") + void getRecentPosts_Popular_Success() { + // Given + EPostSortType sortBy = EPostSortType.POPULAR; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(1L) + .title("인기 게시글 1") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.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(LocalDateTime.now()) + .viewCount(500L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(2L) + .hasNext(false) + .build(); + + given(postRepository.findPopularPostsWithCursor(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.getRecentPosts(sortBy, 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)).findPopularPostsWithCursor(eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findRecentPostsWithCursor(any(), any()); + } + + @Test + @DisplayName("getPostsByCompany() - 특정 회사의 게시글 조회") + void getPostsByCompany_Success() { + // Given + String company = "카카오"; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(1L) + .title("카카오 게시글") + .company(company) + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(50L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .hasNext(false) + .build(); + + given(postRepository.findByCompanyWithCursor(eq(company), 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.getPostsByCompany(company, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).company()).isEqualTo(company); + + verify(postRepository, times(1)).findByCompanyWithCursor(eq(company), eq(lastPostId), any(PageRequest.class)); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..60375c2 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop # 테스트 시작 시 테이블 생성, 종료 시 삭제 + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + datasource: + url: jdbc:h2:mem:testdb # in-memory database + driver-class-name: org.h2.Driver + username: sa + password: + + h2: + console: + enabled: false