diff --git a/docs/ddd-test-refactoring-roadmap.md b/docs/ddd-test-refactoring-roadmap.md index bee19b87..9962d393 100644 --- a/docs/ddd-test-refactoring-roadmap.md +++ b/docs/ddd-test-refactoring-roadmap.md @@ -183,7 +183,10 @@ docs/test-gap-analysis.md - `PersonalizationProfileServiceTest` - `PostSummaryProcessorTest` - `PostSummaryReaderTest` +- `PostSummaryReaderDataJpaTest` - `PostSummaryWriterTest` +- `PostSummaryWriterDataJpaTest` +- `SummaryExtractionServiceTest` ### 2.1 P0 테스트 후보 @@ -521,15 +524,27 @@ ContentChunk = 검색/추천용 projection 내부 값 PostTest - RssFeedItem으로 기술 게시글을 생성한다. - 요약과 짧은 요약을 갱신한다. -- 게시글 키워드를 추가한다. -- 게시글 키워드를 초기화한다. +- 게시글 키워드를 새 목록으로 재구성한다. +- 빈 키워드 목록이면 기존 키워드를 모두 제거한다. - 조회수를 증가시킨다. ``` ```text SummaryExtractionServiceTest - LLM 응답에서 summary, shortSummary, keywords를 파싱한다. -- 잘못된 LLM 응답을 처리한다. +- 잘못된 LLM 응답이면 예외를 던진다. +``` + +```text +PostSummaryReaderDataJpaTest +- summary가 null이거나 빈 문자열인 게시글만 읽는다. +- keyword fetch join 계약을 유지한다. +``` + +```text +PostSummaryWriterDataJpaTest +- H2에서 summary/shortSummary 저장을 검증한다. +- 기존 keyword 삭제 후 새 keyword 재구성을 검증한다. ``` ```text @@ -537,6 +552,12 @@ PostEmbeddingProcessorTest - 제목/요약/본문 청크 임베딩으로 PostDocument를 생성한다. ``` +##### summary pipeline 현재 우려사항 + +- summary 단계와 embedding 단계 상태가 아직 `summary IS NULL OR ''`, `embeddedAt IS NULL` 조합으로 암묵적으로 표현된다. +- malformed LLM JSON은 이제 fail-fast로 막히지만, 예외 타입은 `LlmException`을 재사용해 transport 실패와 response-format 실패를 같은 버킷으로 본다. +- `PostSummaryReader`는 여전히 미요약 backlog를 한 번에 메모리로 읽는 구조라, 데이터가 커지면 paging/streaming reader로 후속 전환이 필요하다. + ##### 리팩터링 후보 - 도메인 문서에서 `Post`를 기술 게시글로 설명 @@ -966,8 +987,11 @@ TechnicalPostIndexed - Bookmark / ReadPost / SearchHistory slice를 `presentation / application / domain / infrastructure` 기준으로 정리 [완료] 5-1. Activity 4.1 2차 정리 - application 서비스의 direct cross-context repository 접근을 application 간 의존으로 전환 -[다음] 6. Post aggregate 테스트 작성 - - PostTest, PostEmbeddingProcessorTest, PostEmbeddingWriterTest +[완료] 6. Post aggregate / summary pipeline 안전망 확장 + - PostTest, SummaryExtractionServiceTest + - PostSummaryReaderDataJpaTest, PostSummaryWriterDataJpaTest +[다음] 6-1. Post embedding pipeline 테스트 작성 + - PostEmbeddingProcessorTest, PostEmbeddingWriterTest [다음] 7. User aggregate 관심사 불변식 정리 [다음] 8. Recommendation 생성 테스트 작성 - MmrServiceTest, LlmRecommendationServiceTest diff --git a/docs/test-gap-analysis.md b/docs/test-gap-analysis.md index 19048d9a..427dbc03 100644 --- a/docs/test-gap-analysis.md +++ b/docs/test-gap-analysis.md @@ -202,29 +202,37 @@ Source는 DDD 전환 초반에는 큰 변경을 피하고, 나중에 이벤트 | `PostControllerV2IntegrationTest` | integration | v2 회사 목록, 다중 회사, 커서 페이징, 최신/인기 정렬 | | `PostRepositoryTest` | JPA | company/recent/popular/detail 쿼리, cursor, company detail | | `PostQueryServiceTest` | unit/mock | 목록/상세 조회, 키워드/북마크 여부 조합 | -| `PostSummaryProcessorTest` | untracked | 요약/키워드 반영 후보 | -| `PostSummaryReaderTest` | untracked | lazy load 후보 | -| `PostSummaryWriterTest` | untracked | summary/keyword JDBC binding 후보 | +| `PostTest` | unit | RssFeedItem 생성, summary 갱신, keyword 재구성 | +| `PostSummaryProcessorTest` | unit/mock | summary/keyword 반영, 예외 전파 시 기존 상태 유지 | +| `PostSummaryReaderTest` | unit/mock | load-once reader, 순차 반환, 빈 결과 처리 | +| `PostSummaryReaderDataJpaTest` | JPA | `summary IS NULL OR ''` query contract, keyword fetch join | +| `PostSummaryWriterTest` | unit/mock | summary update / keyword delete / insert JDBC binding | +| `PostSummaryWriterDataJpaTest` | JPA | H2에서 summary 저장과 keyword 재구성 검증 | +| `SummaryExtractionServiceTest` | unit/mock | LLM JSON parsing, invalid JSON fail-fast, 긴 본문 제한 | #### 평가 Post 조회 API와 repository는 강하다. -하지만 DDD 전환 관점에서 필요한 **`Post` 애그리거트 단위 테스트**가 없다. -또한 검색/추천의 핵심 입력인 **embedding pipeline**이 거의 보호되지 않는다. +이제 `Post` aggregate와 summary pipeline 핵심 경계(`Processor` / `Reader` / `Writer` / `SummaryExtractionService`)는 기본 안전망이 생겼다. +다만 검색/추천의 핵심 입력인 **embedding pipeline**은 여전히 거의 보호되지 않는다. #### 남은 갭 | 우선순위 | 갭 | 이유 | |---|---|---| -| P0 | `PostTest` | `Post = 기술 게시글` 애그리거트 루트의 기본 불변식 보호 필요 | | P0 | `PostEmbeddingProcessorTest` | 제목/요약/청크 임베딩으로 `PostDocument` 생성하는 핵심 pipeline 보호 필요 | | P0 | `PostEmbeddingWriterTest` | Elasticsearch bulk index + `embeddedAt` update 회귀 위험 큼 | | P1 | `ContentChunkerServiceTest` | semantic search 품질에 직접 영향 | | P1 | `PostDocumentTest` | publishedAt serialization, embedding/chunk projection 보호 | -| P1 | `SummaryExtractionServiceTest` | LLM 응답 파싱/실패 처리 보호 필요 | -| P1 | untracked `PostSummary*Test` tracked 반영 여부 결정 | 현재 작성 중 테스트를 공식 안전망에 포함할지 판단 필요 | +| P1 | `PostSummaryWriter` rollback integration test | update/delete/insert 3단계 JDBC write의 원자성 확인 필요 | | P2 | `PostKeywordRepositoryTest` | 키워드 bulk 조회가 여러 서비스 응답 조합에 쓰임 | +#### 현재 우려사항 (merge blocker 아님) + +- summary pipeline 상태가 아직 `summary IS NULL OR ''`, `embeddedAt IS NULL` 조합으로 암묵적으로 표현된다. +- malformed LLM JSON은 이제 fail-fast로 막히지만, 예외 타입은 여전히 `LlmException`을 재사용해 transport 실패와 response-format 실패를 같은 버킷으로 본다. +- `PostSummaryReader`는 여전히 backlog를 한 번에 `List`로 올리는 구조라, 미요약 게시글이 크게 늘면 paging/streaming 전환 검토가 필요하다. + #### 추천 추가 테스트 ```text @@ -232,8 +240,8 @@ PostTest - RssFeedItem으로 기술 게시글을 생성한다. - 생성 시 company는 TechBlog.companyName 또는 RssFeedItem.company 스냅샷을 가진다. - 요약과 짧은 요약을 갱신한다. -- 게시글 키워드를 추가한다. -- 게시글 키워드를 초기화한다. +- 게시글 키워드를 새 목록으로 재구성한다. +- 빈 키워드 목록이면 기존 키워드를 모두 제거한다. - 조회수를 증가시킨다. ``` @@ -543,7 +551,7 @@ src/main/java/com/techfork/domain/notification/entity/NotificationToken.java | 테스트 | 목적 | |---|---| | `ContentChunkerServiceTest` | semantic search 품질 입력 보호 | -| `SummaryExtractionServiceTest` | LLM 요약 응답 parsing 보호 | +| `PostSummaryWriter` rollback integration test | summary/keyword JDBC write 원자성 보호 | | `UserTest` | 사용자 애그리거트 상태 전이 보호 | | `RecommendationCommandServiceTest` | 추천 재생성 use case 보호 | | `RecommendationSchedulerTest` | 일일 추천 생성 스케줄 보호 | diff --git a/src/main/java/com/techfork/domain/post/batch/PostSummaryProcessor.java b/src/main/java/com/techfork/domain/post/batch/PostSummaryProcessor.java index 40b63906..1b6ea8d9 100644 --- a/src/main/java/com/techfork/domain/post/batch/PostSummaryProcessor.java +++ b/src/main/java/com/techfork/domain/post/batch/PostSummaryProcessor.java @@ -1,9 +1,8 @@ package com.techfork.domain.post.batch; -import com.techfork.domain.post.dto.SummaryWithKeywordsDto; -import com.techfork.domain.post.entity.Post; -import com.techfork.domain.post.entity.PostKeyword; -import com.techfork.domain.post.service.SummaryExtractionService; +import com.techfork.domain.post.dto.SummaryWithKeywordsDto; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.service.SummaryExtractionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.configuration.annotation.StepScope; @@ -28,18 +27,12 @@ public Post process(Post post) { SummaryWithKeywordsDto result = summaryExtractionService.extractSummary( post.getTitle(), post.getPlainContent() - ); - - post.updateSummaries(result.summary(), result.shortSummary()); - - // 기존 키워드 삭제 후 새 키워드 추가 - post.clearKeywords(); - result.keywords().forEach(keyword -> { - PostKeyword postKeyword = PostKeyword.create(keyword, post); - post.addKeyword(postKeyword); - }); - - log.debug("요약 및 키워드 추출 완료: {} (키워드 {}개)", post.getTitle(), result.keywords().size()); - return post; - } -} + ); + + post.updateSummaries(result.summary(), result.shortSummary()); + post.replaceKeywords(result.keywords()); + + log.debug("요약 및 키워드 추출 완료: {} (키워드 {}개)", post.getTitle(), result.keywords().size()); + return post; + } +} 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 0a3ecdc1..40872a95 100644 --- a/src/main/java/com/techfork/domain/post/entity/Post.java +++ b/src/main/java/com/techfork/domain/post/entity/Post.java @@ -107,16 +107,21 @@ public static Post create(RssFeedItem item, TechBlog techBlog) { } - public void updateSummaries(String summary, String shortSummary) { - this.summary = summary; - this.shortSummary = shortSummary; - } - - public void addKeyword(PostKeyword keyword) { - this.keywords.add(keyword); - } - - public void clearKeywords() { + public void updateSummaries(String summary, String shortSummary) { + this.summary = summary; + this.shortSummary = shortSummary; + } + + public void replaceKeywords(List keywords) { + clearKeywords(); + keywords.forEach(keyword -> addKeyword(PostKeyword.create(keyword, this))); + } + + private void addKeyword(PostKeyword keyword) { + this.keywords.add(keyword); + } + + private void clearKeywords() { this.keywords.clear(); } } diff --git a/src/main/java/com/techfork/domain/post/service/SummaryExtractionService.java b/src/main/java/com/techfork/domain/post/service/SummaryExtractionService.java index ff811c49..6f283e9f 100644 --- a/src/main/java/com/techfork/domain/post/service/SummaryExtractionService.java +++ b/src/main/java/com/techfork/domain/post/service/SummaryExtractionService.java @@ -1,10 +1,11 @@ package com.techfork.domain.post.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.techfork.domain.post.dto.SummaryWithKeywordsDto; -import com.techfork.global.llm.LlmClient; -import com.techfork.global.util.ContentCleaner; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techfork.domain.post.dto.SummaryWithKeywordsDto; +import com.techfork.global.llm.LlmClient; +import com.techfork.global.llm.exception.LlmException; +import com.techfork.global.util.ContentCleaner; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -49,9 +50,9 @@ public SummaryWithKeywordsDto extractSummary(String title, String content) { log.debug("LLM API 응답 (제목: {}): {}", title, response); - // JSON 응답 파싱 (파싱 실패 시 빈 DTO 반환) - return parseResponse(response.trim()); - } + // JSON 응답 파싱 (파싱 실패 시 예외 전파) + return parseResponse(response.trim()); + } private SummaryWithKeywordsDto parseResponse(String response) { try { @@ -66,11 +67,11 @@ private SummaryWithKeywordsDto parseResponse(String response) { } return new SummaryWithKeywordsDto(summary, shortSummary, keywords); - } catch (Exception e) { - log.error("JSON 응답 파싱 실패: {}", response, e); - return new SummaryWithKeywordsDto("", "", List.of()); - } - } + } catch (Exception e) { + log.error("JSON 응답 파싱 실패: {}", response, e); + throw new LlmException("LLM summary response parsing failed", e); + } + } private String buildUserPrompt(String title, String content) { return String.format(""" diff --git a/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java b/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java new file mode 100644 index 00000000..080f3e40 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java @@ -0,0 +1,108 @@ +package com.techfork.domain.post.batch; + +import com.techfork.domain.post.dto.SummaryWithKeywordsDto; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.fixture.PostFixture; +import com.techfork.domain.post.service.SummaryExtractionService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PostSummaryProcessorTest { + + @Mock + private SummaryExtractionService summaryExtractionService; + + @Nested + @DisplayName("process") + class Process { + + @Test + @DisplayName("추출 결과로 summary를 갱신하고 keyword를 재구성한다") + void updatesSummariesAndRebuildsKeywordsFromExtractionResult() { + PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService); + Post post = createPostWithExistingKeywords(); + PostKeyword oldKeyword1 = post.getKeywords().get(0); + PostKeyword oldKeyword2 = post.getKeywords().get(1); + SummaryWithKeywordsDto summaryWithKeywordsDto = new SummaryWithKeywordsDto( + "새 요약", + "새 짧은 요약", + List.of("AI", "Batch") + ); + given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문")) + .willReturn(summaryWithKeywordsDto); + + Post result = postSummaryProcessor.process(post); + + assertThat(result).isSameAs(post); + assertThat(post.getSummary()).isEqualTo("새 요약"); + assertThat(post.getShortSummary()).isEqualTo("새 짧은 요약"); + assertThat(post.getKeywords()).doesNotContain(oldKeyword1, oldKeyword2); + assertThat(post.getKeywords()) + .extracting(PostKeyword::getKeyword) + .containsExactlyInAnyOrder("AI", "Batch"); + assertThat(post.getKeywords()) + .allSatisfy(keyword -> assertThat(keyword.getPost()).isSameAs(post)); + verify(summaryExtractionService).extractSummary("요약 대상 글", "평문 본문"); + } + + @Test + @DisplayName("추출 결과 keyword가 비어 있으면 기존 keyword를 모두 제거한다") + void clearsExistingKeywordsWhenExtractionReturnsNoKeywords() { + PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService); + Post post = createPostWithExistingKeywords(); + given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문")) + .willReturn(new SummaryWithKeywordsDto("새 요약", "새 짧은 요약", List.of())); + + Post result = postSummaryProcessor.process(post); + + assertThat(result).isSameAs(post); + assertThat(post.getSummary()).isEqualTo("새 요약"); + assertThat(post.getShortSummary()).isEqualTo("새 짧은 요약"); + assertThat(post.getKeywords()).isEmpty(); + } + + @Test + @DisplayName("요약 추출 서비스 예외를 그대로 전파한다") + void propagatesExtractionServiceFailure() { + PostSummaryProcessor postSummaryProcessor = new PostSummaryProcessor(summaryExtractionService); + Post post = createPostWithExistingKeywords(); + PostKeyword oldKeyword1 = post.getKeywords().get(0); + PostKeyword oldKeyword2 = post.getKeywords().get(1); + given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문")) + .willThrow(new IllegalStateException("LLM error")); + + assertThatThrownBy(() -> postSummaryProcessor.process(post)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("LLM error"); + assertThat(post.getSummary()).isEqualTo("기존 요약"); + assertThat(post.getShortSummary()).isEqualTo("기존 짧은 요약"); + assertThat(post.getKeywords()).containsExactly(oldKeyword1, oldKeyword2); + } + + private Post createPostWithExistingKeywords() { + return PostFixture.createPostWithKeywords( + 1L, + "요약 대상 글", + "원문 본문", + "평문 본문", + "TechFork", + "기존 요약", + "기존 짧은 요약", + List.of("기존키워드1", "기존키워드2") + ); + } + } +} diff --git a/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderDataJpaTest.java b/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderDataJpaTest.java new file mode 100644 index 00000000..d667f2f2 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderDataJpaTest.java @@ -0,0 +1,130 @@ +package com.techfork.domain.post.batch; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.source.dto.RssFeedItem; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import com.techfork.domain.post.repository.PostRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceUnitUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class PostSummaryReaderDataJpaTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + @Autowired + private EntityManager entityManager; + + private TechBlog techBlog; + + @BeforeEach + void setUp() { + techBlog = techBlogRepository.save( + TechBlog.create( + "TechFork", + "https://techfork.example.com", + "https://techfork.example.com/rss", + "https://cdn.example.com/logo.png" + ) + ); + } + + @Nested + @DisplayName("read") + class Read { + + @Test + @DisplayName("summary가 null이거나 빈 문자열인 게시글만 읽는다") + void readsOnlyPostsWithNullOrEmptySummary() throws Exception { + Post nullSummaryPost = savePost("null-summary", null, null, List.of("AI")); + Post emptySummaryPost = savePost("empty-summary", "", "", List.of("Batch")); + savePost("completed-summary", "완료 요약", "완료 짧은 요약", List.of("Done")); + + entityManager.clear(); + + PostSummaryReader postSummaryReader = new PostSummaryReader(postRepository); + List readPosts = new ArrayList<>(); + + Post firstRead = postSummaryReader.read(); + Post secondRead = postSummaryReader.read(); + Post thirdRead = postSummaryReader.read(); + + readPosts.add(firstRead); + readPosts.add(secondRead); + + assertThat(thirdRead).isNull(); + assertThat(readPosts) + .extracting(Post::getId) + .containsExactlyInAnyOrder(nullSummaryPost.getId(), emptySummaryPost.getId()); + + PersistenceUnitUtil persistenceUnitUtil = entityManager.getEntityManagerFactory().getPersistenceUnitUtil(); + assertThat(readPosts) + .allSatisfy(post -> assertThat(persistenceUnitUtil.isLoaded(post, "keywords")).isTrue()); + assertThat(readPosts.stream() + .filter(post -> post.getId().equals(nullSummaryPost.getId())) + .findFirst() + .orElseThrow() + .getKeywords()) + .extracting(keyword -> keyword.getKeyword()) + .containsExactly("AI"); + assertThat(readPosts.stream() + .filter(post -> post.getId().equals(emptySummaryPost.getId())) + .findFirst() + .orElseThrow() + .getKeywords()) + .extracting(keyword -> keyword.getKeyword()) + .containsExactly("Batch"); + } + + @Test + @DisplayName("summary가 있는 게시글만 있으면 null을 반환한다") + void returnsNullWhenNoPostsMatchSummaryCondition() throws Exception { + savePost("completed-summary", "완료 요약", "완료 짧은 요약", List.of("Done")); + + entityManager.clear(); + + PostSummaryReader postSummaryReader = new PostSummaryReader(postRepository); + + assertThat(postSummaryReader.read()).isNull(); + } + } + + private Post savePost(String suffix, String summary, String shortSummary, List keywords) { + Post post = Post.create( + RssFeedItem.builder() + .title("요약 대상 글 " + suffix) + .url("https://posts.example.com/" + suffix) + .logoUrl("https://cdn.example.com/logo-" + suffix + ".png") + .thumbnailUrl("https://cdn.example.com/thumb-" + suffix + ".png") + .content("원문 본문 " + suffix) + .plainContent("평문 본문 " + suffix) + .publishedAt(LocalDateTime.of(2026, 5, 10, 10, 0)) + .company("TechFork") + .techBlogId(techBlog.getId()) + .build(), + techBlog + ); + post.updateSummaries(summary, shortSummary); + post.replaceKeywords(keywords); + return postRepository.saveAndFlush(post); + } +} diff --git a/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderTest.java b/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderTest.java new file mode 100644 index 00000000..5e78f3f2 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryReaderTest.java @@ -0,0 +1,71 @@ +package com.techfork.domain.post.batch; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.fixture.PostFixture; +import com.techfork.domain.post.repository.PostRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class PostSummaryReaderTest { + + @Mock + private PostRepository postRepository; + + @Test + @DisplayName("생성만으로는 repository를 조회하지 않는다") + void doesNotQueryRepositoryOnConstruction() { + new PostSummaryReader(postRepository); + + verifyNoInteractions(postRepository); + } + + @Nested + @DisplayName("read") + class Read { + + @Test + @DisplayName("첫 read에서만 repository를 조회하고 Post를 순차적으로 반환한다") + void lazilyLoadsOnceAndReturnsPostsSequentially() { + PostSummaryReader postSummaryReader = new PostSummaryReader(postRepository); + Post firstPost = PostFixture.createPost(1L, "첫 번째 글", "본문1", "평문1", "TechFork", null, null); + Post secondPost = PostFixture.createPost(2L, "두 번째 글", "본문2", "평문2", "TechFork", null, null); + given(postRepository.findWithKeywordsBySummaryIsNull()).willReturn(List.of(firstPost, secondPost)); + + Post firstRead = postSummaryReader.read(); + Post secondRead = postSummaryReader.read(); + Post thirdRead = postSummaryReader.read(); + + assertThat(firstRead).isSameAs(firstPost); + assertThat(secondRead).isSameAs(secondPost); + assertThat(thirdRead).isNull(); + verify(postRepository, times(1)).findWithKeywordsBySummaryIsNull(); + } + + @Test + @DisplayName("조회 결과가 비어 있으면 null을 반환하고 다시 조회하지 않는다") + void returnsNullForEmptyRepositoryResultWithoutReloading() { + PostSummaryReader postSummaryReader = new PostSummaryReader(postRepository); + given(postRepository.findWithKeywordsBySummaryIsNull()).willReturn(List.of()); + + Post firstRead = postSummaryReader.read(); + Post secondRead = postSummaryReader.read(); + + assertThat(firstRead).isNull(); + assertThat(secondRead).isNull(); + verify(postRepository, times(1)).findWithKeywordsBySummaryIsNull(); + } + } +} diff --git a/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterDataJpaTest.java b/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterDataJpaTest.java new file mode 100644 index 00000000..18e026d8 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterDataJpaTest.java @@ -0,0 +1,141 @@ +package com.techfork.domain.post.batch; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.source.dto.RssFeedItem; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.global.util.JdbcBatchExecutor; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.item.Chunk; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(JdbcBatchExecutor.class) +class PostSummaryWriterDataJpaTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + @Autowired + private JdbcBatchExecutor jdbcBatchExecutor; + + @Autowired + private EntityManager entityManager; + + private PostSummaryWriter postSummaryWriter; + private TechBlog techBlog; + + @BeforeEach + void setUp() { + postSummaryWriter = new PostSummaryWriter(jdbcBatchExecutor); + ReflectionTestUtils.setField(postSummaryWriter, "entityManager", entityManager); + + techBlog = techBlogRepository.save( + TechBlog.create( + "TechFork", + "https://techfork.example.com", + "https://techfork.example.com/rss", + "https://cdn.example.com/logo.png" + ) + ); + } + + @Nested + @DisplayName("write") + class Write { + + @Test + @DisplayName("summary를 갱신하고 기존 keyword를 제거한 뒤 새 keyword만 저장한다") + void updatesSummariesAndReplacesPersistedKeywords() { + Post post = savePost("기존 요약", "기존 짧은 요약", List.of("Legacy", "Old")); + + entityManager.clear(); + + Post processedPost = postRepository.findById(post.getId()).orElseThrow(); + processedPost.updateSummaries("새 요약", "새 짧은 요약"); + processedPost.replaceKeywords(List.of("AI", "Batch")); + + postSummaryWriter.write(Chunk.of(processedPost)); + + entityManager.clear(); + + Post reloadedPost = postRepository.findById(post.getId()).orElseThrow(); + + assertThat(reloadedPost.getSummary()).isEqualTo("새 요약"); + assertThat(reloadedPost.getShortSummary()).isEqualTo("새 짧은 요약"); + assertThat(findKeywordNames(post.getId())).containsExactlyInAnyOrder("AI", "Batch"); + } + + @Test + @DisplayName("새 keyword가 없으면 기존 keyword를 모두 삭제하고 summary만 저장한다") + void deletesExistingKeywordsWhenNewKeywordListIsEmpty() { + Post post = savePost("기존 요약", "기존 짧은 요약", List.of("Legacy", "Old")); + + entityManager.clear(); + + Post processedPost = postRepository.findById(post.getId()).orElseThrow(); + processedPost.updateSummaries("새 요약", "새 짧은 요약"); + processedPost.replaceKeywords(List.of()); + + postSummaryWriter.write(Chunk.of(processedPost)); + + entityManager.clear(); + + Post reloadedPost = postRepository.findById(post.getId()).orElseThrow(); + + assertThat(reloadedPost.getSummary()).isEqualTo("새 요약"); + assertThat(reloadedPost.getShortSummary()).isEqualTo("새 짧은 요약"); + assertThat(findKeywordNames(post.getId())).isEmpty(); + } + } + + private Post savePost(String summary, String shortSummary, List keywords) { + Post post = Post.create( + RssFeedItem.builder() + .title("요약 대상 글") + .url("https://posts.example.com/post-summary-writer") + .logoUrl("https://cdn.example.com/post-summary-writer-logo.png") + .thumbnailUrl("https://cdn.example.com/post-summary-writer-thumb.png") + .content("원문 본문") + .plainContent("평문 본문") + .publishedAt(LocalDateTime.of(2026, 5, 10, 10, 0)) + .company("TechFork") + .techBlogId(techBlog.getId()) + .build(), + techBlog + ); + post.updateSummaries(summary, shortSummary); + post.replaceKeywords(keywords); + return postRepository.saveAndFlush(post); + } + + @SuppressWarnings("unchecked") + private List findKeywordNames(Long postId) { + return entityManager.createNativeQuery(""" + SELECT keyword + FROM post_keywords + WHERE post_id = :postId + ORDER BY keyword + """) + .setParameter("postId", postId) + .getResultList(); + } +} diff --git a/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterTest.java b/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterTest.java new file mode 100644 index 00000000..2d10b9b6 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryWriterTest.java @@ -0,0 +1,169 @@ +package com.techfork.domain.post.batch; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.fixture.PostFixture; +import com.techfork.global.util.JdbcBatchExecutor; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; +import org.springframework.test.util.ReflectionTestUtils; + +import java.sql.PreparedStatement; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PostSummaryWriterTest { + + @Mock + private JdbcBatchExecutor jdbcBatchExecutor; + + @Mock + private EntityManager entityManager; + + @Mock + private PreparedStatement updatePreparedStatement; + + @Mock + private PreparedStatement deletePreparedStatement; + + @Mock + private PreparedStatement firstInsertPreparedStatement; + + @Mock + private PreparedStatement secondInsertPreparedStatement; + + @Mock + private PreparedStatement thirdInsertPreparedStatement; + + @Nested + @DisplayName("write") + class Write { + + @Test + @DisplayName("빈 chunk면 JDBC batch와 EntityManager clear를 수행하지 않는다") + void doesNothingForEmptyChunk() throws Exception { + PostSummaryWriter postSummaryWriter = createWriter(); + + postSummaryWriter.write(Chunk.of()); + + verify(jdbcBatchExecutor, never()).batchExecute(any(), anyList(), any()); + verify(entityManager, never()).clear(); + } + + @Test + @DisplayName("게시글 chunk를 summary update, keyword delete, keyword insert로 위임한다") + @SuppressWarnings({"rawtypes", "unchecked"}) + void delegatesUpdateDeleteInsertWithExpectedBindings() throws Exception { + PostSummaryWriter postSummaryWriter = createWriter(); + Post firstPost = createPost(1L, "첫 요약", "첫 짧은 요약", List.of("AI", "Java")); + Post secondPost = createPost(2L, "둘 요약", "둘 짧은 요약", List.of("Spring")); + when(jdbcBatchExecutor.batchExecute(any(), anyList(), any())).thenReturn(2); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor itemsCaptor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor setterCaptor = ArgumentCaptor.forClass(JdbcBatchExecutor.BatchParameterSetter.class); + + postSummaryWriter.write(Chunk.of(firstPost, secondPost)); + + verify(jdbcBatchExecutor, times(3)).batchExecute(sqlCaptor.capture(), itemsCaptor.capture(), setterCaptor.capture()); + assertThat(sqlCaptor.getAllValues().stream().map(PostSummaryWriterTest.this::normalizeSql).toList()) + .containsExactly( + "UPDATE posts SET summary = ?, short_summary = ? WHERE id = ?", + "DELETE FROM post_keywords WHERE post_id = ?", + "INSERT INTO post_keywords (keyword, post_id) VALUES (?, ?)" + ); + assertThat(itemsCaptor.getAllValues().get(0)).containsExactly(firstPost, secondPost); + assertThat(itemsCaptor.getAllValues().get(1)).containsExactly(1L, 2L); + assertThat(itemsCaptor.getAllValues().get(2)).hasSize(3); + + JdbcBatchExecutor.BatchParameterSetter updateSetter = setterCaptor.getAllValues().get(0); + updateSetter.setValues(updatePreparedStatement, firstPost, 0); + verify(updatePreparedStatement).setString(1, "첫 요약"); + verify(updatePreparedStatement).setString(2, "첫 짧은 요약"); + verify(updatePreparedStatement).setLong(3, 1L); + + JdbcBatchExecutor.BatchParameterSetter deleteSetter = setterCaptor.getAllValues().get(1); + deleteSetter.setValues(deletePreparedStatement, 2L, 1); + verify(deletePreparedStatement).setLong(1, 2L); + + JdbcBatchExecutor.BatchParameterSetter insertSetter = setterCaptor.getAllValues().get(2); + List keywordDtos = itemsCaptor.getAllValues().get(2); + insertSetter.setValues(firstInsertPreparedStatement, keywordDtos.get(0), 0); + verify(firstInsertPreparedStatement).setString(1, "AI"); + verify(firstInsertPreparedStatement).setLong(2, 1L); + + insertSetter.setValues(secondInsertPreparedStatement, keywordDtos.get(1), 1); + verify(secondInsertPreparedStatement).setString(1, "Java"); + verify(secondInsertPreparedStatement).setLong(2, 1L); + + insertSetter.setValues(thirdInsertPreparedStatement, keywordDtos.get(2), 2); + verify(thirdInsertPreparedStatement).setString(1, "Spring"); + verify(thirdInsertPreparedStatement).setLong(2, 2L); + verify(entityManager).clear(); + } + + @Test + @DisplayName("삽입할 keyword가 없으면 insert batch를 건너뛰고 update/delete/clear를 수행한다") + @SuppressWarnings({"rawtypes", "unchecked"}) + void skipsInsertWhenFlattenedKeywordListIsEmpty() throws Exception { + PostSummaryWriter postSummaryWriter = createWriter(); + Post firstPost = createPost(1L, "첫 요약", "첫 짧은 요약", List.of()); + Post secondPost = createPost(2L, "둘 요약", "둘 짧은 요약", List.of()); + when(jdbcBatchExecutor.batchExecute(any(), anyList(), any())).thenReturn(2); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor itemsCaptor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor setterCaptor = ArgumentCaptor.forClass(JdbcBatchExecutor.BatchParameterSetter.class); + + postSummaryWriter.write(Chunk.of(firstPost, secondPost)); + + verify(jdbcBatchExecutor, times(2)).batchExecute(sqlCaptor.capture(), itemsCaptor.capture(), setterCaptor.capture()); + assertThat(sqlCaptor.getAllValues().stream().map(PostSummaryWriterTest.this::normalizeSql).toList()) + .containsExactly( + "UPDATE posts SET summary = ?, short_summary = ? WHERE id = ?", + "DELETE FROM post_keywords WHERE post_id = ?" + ); + assertThat(itemsCaptor.getAllValues().get(0)).containsExactly(firstPost, secondPost); + assertThat(itemsCaptor.getAllValues().get(1)).containsExactly(1L, 2L); + verify(entityManager).clear(); + } + + private PostSummaryWriter createWriter() { + PostSummaryWriter postSummaryWriter = new PostSummaryWriter(jdbcBatchExecutor); + ReflectionTestUtils.setField(postSummaryWriter, "entityManager", entityManager); + return postSummaryWriter; + } + + private Post createPost(Long id, String summary, String shortSummary, List keywords) { + return PostFixture.createPostWithKeywords( + id, + "게시글-" + id, + "원문-" + id, + "평문-" + id, + "TechFork", + summary, + shortSummary, + keywords + ); + } + } + + private String normalizeSql(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } +} diff --git a/src/test/java/com/techfork/domain/post/entity/PostTest.java b/src/test/java/com/techfork/domain/post/entity/PostTest.java index d17b5ce8..651ea408 100644 --- a/src/test/java/com/techfork/domain/post/entity/PostTest.java +++ b/src/test/java/com/techfork/domain/post/entity/PostTest.java @@ -2,11 +2,14 @@ import com.techfork.domain.source.dto.RssFeedItem; import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.post.fixture.PostFixture; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class PostTest { @@ -18,19 +21,19 @@ class Create { @Test @DisplayName("RssFeedItem과 TechBlog로 게시글 aggregate를 생성한다") void createsPostAggregateFromRssFeedItem() { - TechBlog techBlog = createTechBlog(); + TechBlog techBlog = PostFixture.createTechBlog(1L, "TechFork"); LocalDateTime publishedAt = LocalDateTime.of(2026, 5, 7, 10, 30); - RssFeedItem rssFeedItem = RssFeedItem.builder() - .title("Post aggregate 설계") - .url("https://posts.example.com/post-aggregate") - .logoUrl("https://cdn.example.com/logo.png") - .thumbnailUrl("https://cdn.example.com/thumb.png") - .content("원문 본문") - .plainContent("평문 본문") - .publishedAt(publishedAt) - .company("TechFork") - .techBlogId(1L) - .build(); + RssFeedItem rssFeedItem = PostFixture.createRssFeedItem( + 1L, + "Post aggregate 설계", + "https://posts.example.com/post-aggregate", + "https://cdn.example.com/logo.png", + "https://cdn.example.com/thumb.png", + "원문 본문", + "평문 본문", + publishedAt, + "TechFork" + ); LocalDateTime beforeCreate = LocalDateTime.now(); Post post = Post.create(rssFeedItem, techBlog); @@ -58,7 +61,7 @@ class UpdateSummaries { @Test @DisplayName("summary와 shortSummary를 함께 갱신한다") void updatesSummaryAndShortSummaryTogether() { - Post post = createPost(); + Post post = PostFixture.createPost(1L, "Post aggregate 설계", "원문 본문", "평문 본문", "TechFork", null, null); post.updateSummaries("새 요약", "새 짧은 요약"); @@ -68,62 +71,34 @@ void updatesSummaryAndShortSummaryTogether() { } @Nested - @DisplayName("addKeyword") - class AddKeyword { + @DisplayName("replaceKeywords") + class ReplaceKeywords { @Test - @DisplayName("keyword를 추가하고 동일한 post 연관관계를 유지한다") - void addsKeywordWithSamePostReference() { - Post post = createPost(); - PostKeyword keyword = PostKeyword.create("AI", post); - - post.addKeyword(keyword); - - assertThat(post.getKeywords()).containsExactly(keyword); - assertThat(keyword.getPost()).isSameAs(post); + @DisplayName("기존 keyword를 제거하고 새 keyword 목록으로 교체한다") + void replacesExistingKeywordsWithNewKeywordNames() { + Post post = PostFixture.createPost(1L, "Post aggregate 설계", "원문 본문", "평문 본문", "TechFork", null, null); + post.replaceKeywords(List.of("Legacy", "Old")); + + post.replaceKeywords(List.of("AI", "Batch")); + + assertThat(post.getKeywords()) + .extracting(PostKeyword::getKeyword) + .containsExactly("AI", "Batch"); + assertThat(post.getKeywords()) + .allSatisfy(keyword -> assertThat(keyword.getPost()).isSameAs(post)); } - } - - @Nested - @DisplayName("clearKeywords") - class ClearKeywords { @Test - @DisplayName("기존 keyword를 모두 제거한다") - void clearsExistingKeywords() { - Post post = createPost(); - post.addKeyword(PostKeyword.create("AI", post)); - post.addKeyword(PostKeyword.create("Batch", post)); + @DisplayName("빈 목록이면 기존 keyword를 모두 제거한다") + void clearsExistingKeywordsWhenKeywordNamesAreEmpty() { + Post post = PostFixture.createPost(1L, "Post aggregate 설계", "원문 본문", "평문 본문", "TechFork", null, null); + post.replaceKeywords(List.of("AI", "Batch")); - post.clearKeywords(); + post.replaceKeywords(List.of()); assertThat(post.getKeywords()).isEmpty(); } } - private Post createPost() { - return Post.create( - RssFeedItem.builder() - .title("Post aggregate 설계") - .url("https://posts.example.com/post-aggregate") - .logoUrl("https://cdn.example.com/logo.png") - .thumbnailUrl("https://cdn.example.com/thumb.png") - .content("원문 본문") - .plainContent("평문 본문") - .publishedAt(LocalDateTime.of(2026, 5, 7, 10, 30)) - .company("TechFork") - .techBlogId(1L) - .build(), - createTechBlog() - ); - } - - private TechBlog createTechBlog() { - return TechBlog.create( - "TechFork", - "https://techfork.example.com", - "https://techfork.example.com/rss", - "https://cdn.example.com/logo.png" - ); - } } diff --git a/src/test/java/com/techfork/domain/post/fixture/PostFixture.java b/src/test/java/com/techfork/domain/post/fixture/PostFixture.java new file mode 100644 index 00000000..5abe4dbf --- /dev/null +++ b/src/test/java/com/techfork/domain/post/fixture/PostFixture.java @@ -0,0 +1,77 @@ +package com.techfork.domain.post.fixture; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.source.dto.RssFeedItem; +import com.techfork.domain.source.entity.TechBlog; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PostFixture { + + private static final LocalDateTime DEFAULT_PUBLISHED_AT = LocalDateTime.of(2026, 4, 13, 7, 0, 0); + + private PostFixture() { + } + + public static TechBlog createTechBlog(Long id, String company) { + TechBlog techBlog = TechBlog.create( + company, + "https://%s.example.com".formatted(company.toLowerCase()), + "https://%s.example.com/rss".formatted(company.toLowerCase()), + "https://cdn.example.com/%s.png".formatted(company.toLowerCase()) + ); + ReflectionTestUtils.setField(techBlog, "id", id); + return techBlog; + } + + public static RssFeedItem createRssFeedItem(Long techBlogId, String title, String fullContent, String plainContent, + String company, LocalDateTime publishedAt) { + return createRssFeedItem( + techBlogId, + title, + "https://posts.example.com/%s".formatted(techBlogId), + "https://cdn.example.com/logo-%s.png".formatted(techBlogId), + "https://cdn.example.com/thumb-%s.png".formatted(techBlogId), + fullContent, + plainContent, + publishedAt, + company + ); + } + + public static RssFeedItem createRssFeedItem(Long techBlogId, String title, String url, String logoUrl, String thumbnailUrl, + String fullContent, String plainContent, LocalDateTime publishedAt, String company) { + return RssFeedItem.builder() + .title(title) + .url(url) + .logoUrl(logoUrl) + .thumbnailUrl(thumbnailUrl) + .content(fullContent) + .plainContent(plainContent) + .publishedAt(publishedAt) + .company(company) + .techBlogId(techBlogId) + .build(); + } + + public static Post createPost(Long id, String title, String fullContent, String plainContent, + String company, String summary, String shortSummary) { + TechBlog techBlog = createTechBlog(id, company); + RssFeedItem rssFeedItem = createRssFeedItem(id, title, fullContent, plainContent, company, DEFAULT_PUBLISHED_AT); + + Post post = Post.create(rssFeedItem, techBlog); + ReflectionTestUtils.setField(post, "id", id); + ReflectionTestUtils.setField(post, "summary", summary); + ReflectionTestUtils.setField(post, "shortSummary", shortSummary); + return post; + } + + public static Post createPostWithKeywords(Long id, String title, String fullContent, String plainContent, + String company, String summary, String shortSummary, List keywords) { + Post post = createPost(id, title, fullContent, plainContent, company, summary, shortSummary); + post.replaceKeywords(keywords); + return post; + } +} diff --git a/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java b/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java new file mode 100644 index 00000000..1df2dbc9 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java @@ -0,0 +1,153 @@ +package com.techfork.domain.post.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techfork.domain.post.dto.SummaryWithKeywordsDto; +import com.techfork.global.llm.LlmClient; +import com.techfork.global.llm.exception.LlmException; +import com.techfork.global.util.ContentCleaner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SummaryExtractionServiceTest { + + @Mock + private LlmClient llmClient; + + private SummaryExtractionService summaryExtractionService; + + @BeforeEach + void setUp() { + summaryExtractionService = new SummaryExtractionService(llmClient, new ObjectMapper()); + } + + @Nested + @DisplayName("extractSummary") + class ExtractSummary { + + @Test + @DisplayName("LLM JSON 응답에서 summary, shortSummary, keywords를 파싱한다") + void parsesSummaryShortSummaryAndKeywordsFromJsonResponse() { + given(llmClient.call(anyString(), anyString())) + .willReturn(""" + { + "summary": "상세 요약", + "shortSummary": "짧은 요약", + "keywords": ["AI", "Batch"] + } + """); + + SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); + + assertThat(result.summary()).isEqualTo("상세 요약"); + assertThat(result.shortSummary()).isEqualTo("짧은 요약"); + assertThat(result.keywords()).containsExactly("AI", "Batch"); + } + + @Test + @DisplayName("shortSummary가 없으면 빈 문자열을 반환한다") + void returnsEmptyStringWhenShortSummaryIsMissing() { + given(llmClient.call(anyString(), anyString())) + .willReturn(""" + { + "summary": "상세 요약", + "keywords": ["AI"] + } + """); + + SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); + + assertThat(result.summary()).isEqualTo("상세 요약"); + assertThat(result.shortSummary()).isEmpty(); + assertThat(result.keywords()).containsExactly("AI"); + } + + @Test + @DisplayName("keywords가 없으면 빈 리스트를 반환한다") + void returnsEmptyListWhenKeywordsAreMissing() { + given(llmClient.call(anyString(), anyString())) + .willReturn(""" + { + "summary": "상세 요약", + "shortSummary": "짧은 요약" + } + """); + + SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); + + assertThat(result.summary()).isEqualTo("상세 요약"); + assertThat(result.shortSummary()).isEqualTo("짧은 요약"); + assertThat(result.keywords()).isEmpty(); + } + + @Test + @DisplayName("keywords가 배열이 아니면 빈 리스트를 반환한다") + void returnsEmptyListWhenKeywordsIsNotArray() { + given(llmClient.call(anyString(), anyString())) + .willReturn(""" + { + "summary": "상세 요약", + "shortSummary": "짧은 요약", + "keywords": "AI" + } + """); + + SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); + + assertThat(result.summary()).isEqualTo("상세 요약"); + assertThat(result.shortSummary()).isEqualTo("짧은 요약"); + assertThat(result.keywords()).isEmpty(); + } + + @Test + @DisplayName("유효하지 않은 JSON 응답이면 예외를 던진다") + void throwsExceptionWhenJsonParsingFails() { + given(llmClient.call(anyString(), anyString())) + .willReturn("not-json"); + + assertThatThrownBy(() -> summaryExtractionService.extractSummary("제목", "본문")) + .isInstanceOf(LlmException.class) + .hasMessageContaining("LLM summary response parsing failed"); + } + + @Test + @DisplayName("본문이 너무 길면 50000자로 정제 후 제한한 내용을 프롬프트에 사용한다") + void usesCleanedAndLimitedContentWhenBodyIsTooLong() { + String longContent = "word ".repeat(15000) + "TRAILING_MARKER"; + String expectedContent = ContentCleaner.cleanAndLimit(longContent, 50000); + given(llmClient.call(anyString(), anyString())) + .willReturn(""" + { + "summary": "상세 요약", + "shortSummary": "짧은 요약", + "keywords": ["AI"] + } + """); + + summaryExtractionService.extractSummary("긴 본문 제목", longContent); + + ArgumentCaptor userPromptCaptor = ArgumentCaptor.forClass(String.class); + verify(llmClient).call(anyString(), userPromptCaptor.capture()); + String userPrompt = userPromptCaptor.getValue(); + + assertThat(expectedContent.length()).isLessThan(longContent.length()); + assertThat(userPrompt).contains("제목: 긴 본문 제목"); + assertThat(userPrompt).contains(expectedContent); + assertThat(userPrompt).doesNotContain("TRAILING_MARKER"); + } + } +}