From 1a643319e43d424b115bb153bbb594ccc3984bd7 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 12:44:21 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20Post=20keyword=20=EC=BA=A1?= =?UTF-8?q?=EC=8A=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/batch/PostSummaryProcessor.java | 31 +++++++--------- .../com/techfork/domain/post/entity/Post.java | 25 +++++++------ .../techfork/domain/post/entity/PostTest.java | 35 +++++++++---------- 3 files changed, 44 insertions(+), 47 deletions(-) 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/test/java/com/techfork/domain/post/entity/PostTest.java b/src/test/java/com/techfork/domain/post/entity/PostTest.java index d17b5ce8..634609f7 100644 --- a/src/test/java/com/techfork/domain/post/entity/PostTest.java +++ b/src/test/java/com/techfork/domain/post/entity/PostTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class PostTest { @@ -68,34 +70,31 @@ void updatesSummaryAndShortSummaryTogether() { } @Nested - @DisplayName("addKeyword") - class AddKeyword { + @DisplayName("replaceKeywords") + class ReplaceKeywords { @Test - @DisplayName("keyword를 추가하고 동일한 post 연관관계를 유지한다") - void addsKeywordWithSamePostReference() { + @DisplayName("기존 keyword를 제거하고 새 keyword 목록으로 교체한다") + void replacesExistingKeywordsWithNewKeywordNames() { Post post = createPost(); - PostKeyword keyword = PostKeyword.create("AI", post); + post.replaceKeywords(List.of("Legacy", "Old")); - post.addKeyword(keyword); + post.replaceKeywords(List.of("AI", "Batch")); - assertThat(post.getKeywords()).containsExactly(keyword); - assertThat(keyword.getPost()).isSameAs(post); + 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() { + @DisplayName("빈 목록이면 기존 keyword를 모두 제거한다") + void clearsExistingKeywordsWhenKeywordNamesAreEmpty() { Post post = createPost(); - post.addKeyword(PostKeyword.create("AI", post)); - post.addKeyword(PostKeyword.create("Batch", post)); + post.replaceKeywords(List.of("AI", "Batch")); - post.clearKeywords(); + post.replaceKeywords(List.of()); assertThat(post.getKeywords()).isEmpty(); } From d45f2416ff9acd5cbdb6376f4fb86b73ceb85b3a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 12:54:33 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=BD=EC=8A=A4=EC=B3=90=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techfork/domain/post/entity/PostTest.java | 56 ++++---------- .../domain/post/fixture/PostFixture.java | 77 +++++++++++++++++++ 2 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 src/test/java/com/techfork/domain/post/fixture/PostFixture.java 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 634609f7..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,6 +2,7 @@ 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; @@ -20,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); @@ -60,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("새 요약", "새 짧은 요약"); @@ -76,7 +77,7 @@ class ReplaceKeywords { @Test @DisplayName("기존 keyword를 제거하고 새 keyword 목록으로 교체한다") void replacesExistingKeywordsWithNewKeywordNames() { - Post post = createPost(); + Post post = PostFixture.createPost(1L, "Post aggregate 설계", "원문 본문", "평문 본문", "TechFork", null, null); post.replaceKeywords(List.of("Legacy", "Old")); post.replaceKeywords(List.of("AI", "Batch")); @@ -91,7 +92,7 @@ void replacesExistingKeywordsWithNewKeywordNames() { @Test @DisplayName("빈 목록이면 기존 keyword를 모두 제거한다") void clearsExistingKeywordsWhenKeywordNamesAreEmpty() { - Post post = createPost(); + Post post = PostFixture.createPost(1L, "Post aggregate 설계", "원문 본문", "평문 본문", "TechFork", null, null); post.replaceKeywords(List.of("AI", "Batch")); post.replaceKeywords(List.of()); @@ -100,29 +101,4 @@ void clearsExistingKeywordsWhenKeywordNamesAreEmpty() { } } - 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; + } +} From a265800a5f12e360f7e94d34528fe7176bd691e7 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 12:57:03 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test:=20summary=20pipeline=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/batch/PostSummaryProcessorTest.java | 103 +++++++++++ .../post/batch/PostSummaryReaderTest.java | 71 ++++++++ .../post/batch/PostSummaryWriterTest.java | 169 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java create mode 100644 src/test/java/com/techfork/domain/post/batch/PostSummaryReaderTest.java create mode 100644 src/test/java/com/techfork/domain/post/batch/PostSummaryWriterTest.java 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..348d40c0 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java @@ -0,0 +1,103 @@ +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(); + given(summaryExtractionService.extractSummary("요약 대상 글", "평문 본문")) + .willThrow(new IllegalStateException("LLM error")); + + assertThatThrownBy(() -> postSummaryProcessor.process(post)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("LLM error"); + } + + private Post createPostWithExistingKeywords() { + return PostFixture.createPostWithKeywords( + 1L, + "요약 대상 글", + "원문 본문", + "평문 본문", + "TechFork", + "기존 요약", + "기존 짧은 요약", + List.of("기존키워드1", "기존키워드2") + ); + } + } +} 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/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(); + } +} From 5dfae41847b9e694c1a3372c0292a84f1033aa2a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 12:57:16 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20summary=20extraction=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SummaryExtractionServiceTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java 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..d9e274f6 --- /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.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.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 응답이면 빈 DTO를 반환한다") + void returnsEmptyDtoWhenJsonParsingFails() { + given(llmClient.call(anyString(), anyString())) + .willReturn("not-json"); + + SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); + + assertThat(result.summary()).isEmpty(); + assertThat(result.shortSummary()).isEmpty(); + assertThat(result.keywords()).isEmpty(); + } + + @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"); + } + } +} From 49944c3fe1b4614b762e3b8f3811b320e3d2697c Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 13:06:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?improve:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20js?= =?UTF-8?q?on=EC=9D=B4=20=EC=98=AC=20=EA=B2=BD=EC=9A=B0=20=EB=B9=88=20json?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EB=8D=98?= =?UTF-8?q?=EC=A7=80=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20->=20Skip=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SummaryExtractionService.java | 27 ++++++++++--------- .../post/batch/PostSummaryProcessorTest.java | 5 ++++ .../service/SummaryExtractionServiceTest.java | 14 +++++----- 3 files changed, 26 insertions(+), 20 deletions(-) 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 index 348d40c0..080f3e40 100644 --- a/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java +++ b/src/test/java/com/techfork/domain/post/batch/PostSummaryProcessorTest.java @@ -79,12 +79,17 @@ void clearsExistingKeywordsWhenExtractionReturnsNoKeywords() { 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() { diff --git a/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java b/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java index d9e274f6..1df2dbc9 100644 --- a/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java +++ b/src/test/java/com/techfork/domain/post/service/SummaryExtractionServiceTest.java @@ -3,6 +3,7 @@ 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; @@ -16,6 +17,7 @@ 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; @@ -112,16 +114,14 @@ void returnsEmptyListWhenKeywordsIsNotArray() { } @Test - @DisplayName("유효하지 않은 JSON 응답이면 빈 DTO를 반환한다") - void returnsEmptyDtoWhenJsonParsingFails() { + @DisplayName("유효하지 않은 JSON 응답이면 예외를 던진다") + void throwsExceptionWhenJsonParsingFails() { given(llmClient.call(anyString(), anyString())) .willReturn("not-json"); - SummaryWithKeywordsDto result = summaryExtractionService.extractSummary("제목", "본문"); - - assertThat(result.summary()).isEmpty(); - assertThat(result.shortSummary()).isEmpty(); - assertThat(result.keywords()).isEmpty(); + assertThatThrownBy(() -> summaryExtractionService.extractSummary("제목", "본문")) + .isInstanceOf(LlmException.class) + .hasMessageContaining("LLM summary response parsing failed"); } @Test From 9b7db823cdabc207c190da6d7c63e418ad5ef561 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 13:13:28 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20PostSummaryWriter=EC=9D=98=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20H2?= =?UTF-8?q?=20DB=EC=99=80=20=EC=97=B0=EB=8F=99=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/PostSummaryWriterDataJpaTest.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/batch/PostSummaryWriterDataJpaTest.java 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(); + } +} From 7881f02b582ec2648e39e2c6577dd8d0781738b5 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 13:14:54 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20PostSummaryReader=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20=EC=9E=91?= =?UTF-8?q?=EB=8F=99=ED=95=98=EB=8A=94=EC=A7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/PostSummaryReaderDataJpaTest.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/batch/PostSummaryReaderDataJpaTest.java 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); + } +} From 54ff52811936ac89d39c25c71e2a0b8943ea638a Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 10 May 2026 13:23:21 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=EB=AC=B8=EC=84=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ddd-test-refactoring-roadmap.md | 34 ++++++++++++++++++++++++---- docs/test-gap-analysis.md | 30 +++++++++++++++--------- 2 files changed, 48 insertions(+), 16 deletions(-) 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` | 일일 추천 생성 스케줄 보호 |