diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java index d7213448..fa4a2508 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java @@ -7,12 +7,14 @@ import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; +import com.sofa.linkiving.domain.link.dto.request.SummaryUpdateReq; import com.sofa.linkiving.domain.link.dto.response.LinkCardsRes; import com.sofa.linkiving.domain.link.dto.response.LinkDetailRes; import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; +import com.sofa.linkiving.domain.link.dto.response.SummaryRes; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.common.BaseResponse; @@ -98,4 +100,11 @@ BaseResponse recreateSummary( @Valid Format format, Member member ); + + @Operation(summary = "새로운 요약 선택", description = "신규 요약으로 요약 내용을 수정합니다.") + BaseResponse updateSummary( + Long id, + @Valid SummaryUpdateReq request, + Member member + ); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java index 408180ae..85371b40 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java @@ -16,12 +16,14 @@ import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; +import com.sofa.linkiving.domain.link.dto.request.SummaryUpdateReq; import com.sofa.linkiving.domain.link.dto.response.LinkCardsRes; import com.sofa.linkiving.domain.link.dto.response.LinkDetailRes; import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; +import com.sofa.linkiving.domain.link.dto.response.SummaryRes; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.facade.LinkFacade; import com.sofa.linkiving.domain.member.entity.Member; @@ -152,4 +154,15 @@ public BaseResponse recreateSummary( RecreateSummaryResponse response = linkFacade.recreateSummary(member, id, format); return BaseResponse.success(response, "요약 재성성 완료"); } + + @Override + @PatchMapping("/{id}/summary") + public BaseResponse updateSummary( + @PathVariable Long id, + @RequestBody SummaryUpdateReq request, + @AuthMember Member member + ) { + SummaryRes response = linkFacade.updateSummary(id, member, request.summary(), request.format()); + return BaseResponse.success(response, "요약 수정 완료"); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/request/SummaryUpdateReq.java b/src/main/java/com/sofa/linkiving/domain/link/dto/request/SummaryUpdateReq.java new file mode 100644 index 00000000..a6cb5a0c --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/request/SummaryUpdateReq.java @@ -0,0 +1,16 @@ +package com.sofa.linkiving.domain.link.dto.request; + +import com.sofa.linkiving.domain.link.enums.Format; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record SummaryUpdateReq( + @NotNull(message = "요약 내용은 필수입니다.") + @Schema(description = "요약 내용", example = "새롭게 선택한 요약 내용") + String summary, + @NotNull(message = "요약 포맷 정보는 필수입니다.") + @Schema(description = "요약 포맷 정보 (CONCISE, DETAILED)", example = "CONCISE") + Format format +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkDetailRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkDetailRes.java index 200346a6..91dcf443 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkDetailRes.java +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkDetailRes.java @@ -40,17 +40,4 @@ public static LinkDetailRes of(Link link, Summary summary) { ); } - public record SummaryRes( - @Schema(description = "요약 ID") - Long id, - @Schema(description = "요약 내용", example = "이 링크는 예시 링크입니다.") - String content - ) { - public static SummaryRes from(Summary summary) { - if (summary == null) { - return null; - } - return new SummaryRes(summary.getId(), summary.getContent()); - } - } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/SummaryRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/SummaryRes.java new file mode 100644 index 00000000..4d00d62c --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/SummaryRes.java @@ -0,0 +1,19 @@ +package com.sofa.linkiving.domain.link.dto.response; + +import com.sofa.linkiving.domain.link.entity.Summary; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SummaryRes( + @Schema(description = "요약 ID") + Long id, + @Schema(description = "요약 내용", example = "이 링크는 예시 링크입니다.") + String content +) { + public static SummaryRes from(Summary summary) { + if (summary == null) { + return null; + } + return new SummaryRes(summary.getId(), summary.getContent()); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java index 2bd06075..1149f4d7 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java @@ -13,7 +13,9 @@ import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; +import com.sofa.linkiving.domain.link.dto.response.SummaryRes; import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.service.LinkService; import com.sofa.linkiving.domain.link.service.SummaryService; @@ -82,7 +84,7 @@ public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Forma String url = linkService.getLink(linkId, member).getUrl(); String existingSummary = summaryService.getSummary(linkId).getContent(); - String newSummary = summaryService.createSummary(linkId, url, format); + String newSummary = summaryService.initialSummary(linkId, url, format); String comparison = summaryService.comparisonSummary(existingSummary, newSummary); @@ -98,4 +100,11 @@ public MetaScrapeRes scrapeMetadata(String url) { OgTagDto ogTag = ogTagCrawler.crawl(url); return MetaScrapeRes.from(ogTag); } + + public SummaryRes updateSummary(Long id, Member member, String content, Format format) { + Link link = linkService.getLink(id, member); + Summary summary = summaryService.createSummary(link, format, content); + summaryService.selectSummary(link.getId(), summary.getId()); + return SummaryRes.from(summary); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java index 9af98652..3d392f02 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryCommandService.java @@ -2,6 +2,9 @@ import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.repository.SummaryRepository; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -24,5 +27,15 @@ public void selectSummary(Long linkId, Long summaryId) { throw new BusinessException(LinkErrorCode.SUMMARY_NOT_FOUND); } } + + public Summary save(Link link, Format format, String content) { + return summaryRepository.save( + Summary.builder() + .link(link) + .format(format) + .content(content) + .build() + ); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java index f867945a..f92a96c0 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryService.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import com.sofa.linkiving.domain.link.ai.AiSummaryClient; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.enums.Format; @@ -12,9 +13,10 @@ @RequiredArgsConstructor public class SummaryService { private final SummaryQueryService summaryQueryService; + private final SummaryCommandService summaryCommandService; private final AiSummaryClient aiSummaryClient; - public String createSummary(Long linkId, String url, Format format) { + public String initialSummary(Long linkId, String url, Format format) { return aiSummaryClient.generateSummary(linkId, url, format); } @@ -25,4 +27,12 @@ public String comparisonSummary(String existingSummary, String newSummary) { public Summary getSummary(Long linkId) { return summaryQueryService.getSummary(linkId); } + + public Summary createSummary(Link link, Format format, String content) { + return summaryCommandService.save(link, format, content); + } + + public void selectSummary(Long linkId, Long summaryId) { + summaryCommandService.selectSummary(linkId, summaryId); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java index 9280a7ba..3814829d 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java @@ -116,7 +116,7 @@ void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { given(summaryService.getSummary(linkId)).willReturn(mockSummary); // 3. SummaryService (새 요약 생성 및 비교) - given(summaryService.createSummary(linkId, url, format)).willReturn(newSummaryBody); + given(summaryService.initialSummary(linkId, url, format)).willReturn(newSummaryBody); given(summaryService.comparisonSummary(existingSummaryBody, newSummaryBody)).willReturn(comparisonBody); // when @@ -130,7 +130,7 @@ void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { // verify verify(summaryService).getSummary(linkId); - verify(summaryService).createSummary(linkId, url, format); + verify(summaryService).initialSummary(linkId, url, format); verify(summaryService).comparisonSummary(existingSummaryBody, newSummaryBody); } diff --git a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java index 5cd644f7..920dd5a8 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java @@ -28,6 +28,7 @@ import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; +import com.sofa.linkiving.domain.link.dto.request.SummaryUpdateReq; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.enums.Format; @@ -724,4 +725,29 @@ void shouldFailWhenMetaScrapeUrlIsMissing() throws Exception { ) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("updateSummary API: PATCH 요청 시 요약 정보를 수정하고 200 OK를 반환한다") + void updateSummaryApi_ShouldReturn200Ok() throws Exception { + // given + Link savedLink = linkRepository.save(Link.builder() + .member(testMember) + .url("https://example.com/article") + .title("테스트 링크") + .build()); + Long linkId = savedLink.getId(); + + SummaryUpdateReq request = new SummaryUpdateReq("수정된 요약 텍스트", Format.DETAILED); + + // when & then + mockMvc.perform(patch(BASE_URL + "/{id}/summary", linkId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf()) + .with(user(testUserDetails)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("요약 수정 완료")) + .andExpect(jsonPath("$.data.content").value("수정된 요약 텍스트")); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java index b827d515..02af0929 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,6 +14,7 @@ import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.member.entity.Member; @DataJpaTest @@ -119,4 +121,65 @@ void shouldReturnEmptyWhenLinkListIsEmpty() { // then assertThat(result).isEmpty(); } + + @Test + @DisplayName("clearSelectedByLinkId 및 selectByIdAndLinkId 실행 시 selected 요약이 단 1개만 존재한다") + void shouldEnsureOnlyOneSummaryIsSelected() { + // given + Member member = Member.builder() + .email("test@test.com") + .password("pw") + .build(); + em.persist(member); + Link link = Link.builder() + .member(member) + .url("http://test.com") + .title("t1") + .build(); + em.persist(link); + + // 1. 과거 요약 1 (기존 선택됨) + Summary oldSummary1 = Summary.builder() + .link(link) + .content("과거 요약 1") + .format(Format.CONCISE) + .selected(true) + .build(); + + // 2. 과거 요약 2 (선택 안됨) + Summary oldSummary2 = Summary.builder() + .link(link) + .content("과거 요약 2") + .format(Format.DETAILED) + .selected(false) + .build(); + + // 3. 방금 새로 수정한 요약 (아직 선택 안됨) + Summary newSummary = Summary.builder() + .link(link) + .content("새로 수정한 요약") + .format(Format.DETAILED).selected(false) + .build(); + + em.persist(oldSummary1); + em.persist(oldSummary2); + em.persist(newSummary); + + // when - Service 계층에서 수행하는 두 가지 쿼리를 순서대로 실행 + + summaryRepository.clearSelectedByLinkId(link.getId()); + summaryRepository.selectByIdAndLinkId(newSummary.getId(), link.getId()); + + // then + Optional selectedSummary = summaryRepository.findByLinkIdAndSelectedTrue(link.getId()); + assertThat(selectedSummary).isPresent(); + assertThat(selectedSummary.get().getId()).isEqualTo(newSummary.getId()); + + long selectedCount = summaryRepository.findAll().stream() + .filter(s -> s.getLink().getId().equals(link.getId())) + .filter(Summary::isSelected) + .count(); + + assertThat(selectedCount).isEqualTo(1L); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java index 0ef8695f..16e125b8 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryServiceTest.java @@ -26,8 +26,8 @@ public class SummaryServiceTest { private AiSummaryClient aiSummaryClient; @Test - @DisplayName("createSummary 호출 시 AiSummaryClient에게 위임한다") - void shouldCallGenerateSummaryWhenCreateSummary() { + @DisplayName("generateSummary 호출 시 SummaryClient에게 위임한다") + void shouldCallGenerateSummaryWhenInitialSummary() { // given Long linkId = 1L; String url = "https://example.com"; @@ -37,7 +37,7 @@ void shouldCallGenerateSummaryWhenCreateSummary() { given(aiSummaryClient.generateSummary(linkId, url, format)).willReturn(expectedResult); // when - String result = summaryService.createSummary(linkId, url, format); + String result = summaryService.initialSummary(linkId, url, format); // then assertThat(result).isEqualTo(expectedResult);