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 be60ff70..d7213448 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 @@ -1,13 +1,14 @@ package com.sofa.linkiving.domain.link.controller; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.validation.annotation.Validated; import com.sofa.linkiving.domain.link.dto.request.LinkCreateReq; import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq; 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.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; @@ -17,10 +18,14 @@ import com.sofa.linkiving.global.common.BaseResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +@Validated @Tag(name = "Link", description = "링크 관리 API") public interface LinkApi { @@ -55,16 +60,21 @@ BaseResponse deleteLink( Member member ); - @Operation(summary = "링크 조회", description = "링크 상세 정보를 조회합니다") - BaseResponse getLink( + @Operation(summary = "링크 상세 조회", description = "링크 상세 정보를 조회합니다") + BaseResponse getLink( Long id, Member member ); - @Operation(summary = "링크 목록 조회", description = "저장된 링크 목록을 페이징하여 조회합니다") - BaseResponse> getLinkList( - Pageable pageable, - Member member + @Operation(summary = "링크 카드 목록 조회", description = "저장된 링크 목록을 무한 스크롤 방식으로 조회합니다") + BaseResponse getLinkList( + Member member, + @Parameter(description = "페이징을 위한 마지막 메시지 ID, 첫 조회 시 null") Long lastId, + + @Parameter(description = "한번에 조회할 데이터 갯수") + @Min(value = 1, message = "최소 1개 이상 조회해야 합니다.") + @Max(value = 50, message = "한 번에 최대 50개까지만 조회할 수 있습니다.") + int size ); @Operation(summary = "링크 제목 수정", description = "링크 제목만 수정합니다") @@ -84,7 +94,8 @@ BaseResponse updateMemo( @Operation(summary = "요약 재생성", description = "요약을 재생성 하고 신규 요약 기존 요약, 기존 및 신규 요약 비교 정보을 제공합니다.") BaseResponse recreateSummary( Long id, - @Valid @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format, + @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") + @Valid Format format, 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 ce6c0d36..a76916a0 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 @@ -1,8 +1,6 @@ package com.sofa.linkiving.domain.link.controller; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -19,6 +17,8 @@ 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.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; @@ -102,21 +102,22 @@ public BaseResponse deleteLink( @Override @GetMapping("/{id}") - public BaseResponse getLink( + public BaseResponse getLink( @PathVariable Long id, @AuthMember Member member ) { - LinkRes response = linkFacade.getLink(id, member); + LinkDetailRes response = linkFacade.getLinkDetail(id, member); return BaseResponse.success(response, "링크 조회 완료"); } @Override @GetMapping - public BaseResponse> getLinkList( - @PageableDefault(size = 20) Pageable pageable, - @AuthMember Member member + public BaseResponse getLinkList( + @AuthMember Member member, + @RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "20") int size ) { - Page response = linkFacade.getLinkList(member, pageable); + LinkCardsRes response = linkFacade.getLinkCards(member, lastId, size); return BaseResponse.success(response, "링크 목록 조회 완료"); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinkDto.java b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinkDto.java new file mode 100644 index 00000000..5feee7e3 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinkDto.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.domain.link.dto.internal; + +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; + +public record LinkDto( + Link link, + Summary summary +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinksDto.java b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinksDto.java new file mode 100644 index 00000000..83ba2eb7 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/LinksDto.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.domain.link.dto.internal; + +import java.util.List; + +public record LinksDto( + List linkDtos, + boolean hasNext +) { +} + diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/OgTagDto.java similarity index 78% rename from src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java rename to src/main/java/com/sofa/linkiving/domain/link/dto/internal/OgTagDto.java index e0eb3519..9eaeb5ff 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/internal/OgTagDto.java @@ -1,4 +1,4 @@ -package com.sofa.linkiving.domain.link.dto; +package com.sofa.linkiving.domain.link.dto.internal; import lombok.Builder; diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java new file mode 100644 index 00000000..e04aad77 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java @@ -0,0 +1,57 @@ +package com.sofa.linkiving.domain.link.dto.response; + +import java.util.List; + +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LinkCardsRes( + @Schema(description = "링크 목록") + List links, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext, + @Schema(description = "마지막 메시지 ID (다음 요청 커서용)") + Long lastId +) { + public static LinkCardsRes of(LinksDto linksDto) { + List links = linksDto.linkDtos().stream().map(LinkCardRes::from).toList(); + Long lastId = links.isEmpty() ? null : links.get(links.size() - 1).id(); + + return new LinkCardsRes(links, linksDto.hasNext(), lastId); + } + + public record LinkCardRes( + @Schema(description = "링크 ID") + Long id, + + @Schema(description = "링크 URL", example = "https://example.com") + String url, + + @Schema(description = "링크 제목", example = "유용한 개발 자료") + String title, + + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String imageUrl, + + @Schema(description = "요약 정보") + String summary + ) { + public static LinkCardRes from(LinkDto dto) { + return of(dto.link(), dto.summary()); + } + + public static LinkCardRes of(Link link, Summary summary) { + return new LinkCardRes( + link.getId(), + link.getUrl(), + link.getTitle(), + link.getImageUrl(), + summary == null ? null : summary.getContent() + ); + } + } +} 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 new file mode 100644 index 00000000..200346a6 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkDetailRes.java @@ -0,0 +1,56 @@ +package com.sofa.linkiving.domain.link.dto.response; + +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LinkDetailRes( + @Schema(description = "링크 ID") + Long id, + + @Schema(description = "링크 URL", example = "https://example.com") + String url, + + @Schema(description = "링크 제목", example = "유용한 개발 자료") + String title, + + @Schema(description = "메모", example = "나중에 읽어볼 것") + String memo, + + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String imageUrl, + + @Schema(description = "요약 정보") + SummaryRes summary +) { + public static LinkDetailRes from(LinkDto dto) { + return of(dto.link(), dto.summary()); + } + + public static LinkDetailRes of(Link link, Summary summary) { + return new LinkDetailRes( + link.getId(), + link.getUrl(), + link.getTitle(), + link.getMemo(), + link.getImageUrl(), + SummaryRes.from(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/MetaScrapeRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java index 239f3ae1..17bad9ca 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java @@ -1,6 +1,6 @@ package com.sofa.linkiving.domain.link.dto.response; -import com.sofa.linkiving.domain.link.dto.OgTagDto; +import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sofa/linkiving/domain/link/entity/Summary.java b/src/main/java/com/sofa/linkiving/domain/link/entity/Summary.java index 074fb324..144b475e 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/entity/Summary.java +++ b/src/main/java/com/sofa/linkiving/domain/link/entity/Summary.java @@ -28,11 +28,11 @@ public class Summary extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String content; - @Column(name = "selected", columnDefinition = "TEXT") - private String selected; + @Column(name = "selected") + private boolean selected; @Builder - public Summary(Link link, Format format, String content, String select) { + public Summary(Link link, Format format, String content, boolean select) { this.link = link; this.format = format; this.content = content; 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 794bd2df..b8fb481c 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 @@ -1,11 +1,13 @@ package com.sofa.linkiving.domain.link.facade; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.sofa.linkiving.domain.link.dto.OgTagDto; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; +import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; +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; @@ -53,15 +55,15 @@ public void deleteLink(Long linkId, Member member) { } @Transactional(readOnly = true) - public LinkRes getLink(Long linkId, Member member) { - Link link = linkService.getLink(linkId, member); - return LinkRes.from(link); + public LinkDetailRes getLinkDetail(Long linkId, Member member) { + LinkDto linkDto = linkService.getLinkWithSummary(linkId, member); + return LinkDetailRes.from(linkDto); } @Transactional(readOnly = true) - public Page getLinkList(Member member, Pageable pageable) { - Page links = linkService.getLinkList(member, pageable); - return links.map(LinkRes::from); + public LinkCardsRes getLinkCards(Member member, Long lastId, int size) { + LinksDto linkDtos = linkService.getLinksWithSummary(member, lastId, size); + return LinkCardsRes.of(linkDtos); } @Transactional(readOnly = true) diff --git a/src/main/java/com/sofa/linkiving/domain/link/repository/LinkRepository.java b/src/main/java/com/sofa/linkiving/domain/link/repository/LinkRepository.java index 6d58fa6c..ca90c4c2 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/repository/LinkRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/link/repository/LinkRepository.java @@ -1,13 +1,14 @@ package com.sofa.linkiving.domain.link.repository; +import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.member.entity.Member; @@ -15,10 +16,40 @@ public interface LinkRepository extends JpaRepository { Optional findByIdAndMember(Long id, Member member); - Page findByMemberAndIsDeleteFalse(Member member, Pageable pageable); - boolean existsByMemberAndUrlAndIsDeleteFalse(Member member, String url); - @Query("SELECT l.id FROM Link l WHERE l.member = :member AND l.url = :url AND l.isDelete = false") + @Query(""" + SELECT l.id + FROM Link l + WHERE l.member = :member AND l.url = :url AND l.isDelete = false + """) Optional findIdByMemberAndUrlAndIsDeleteFalse(@Param("member") Member member, @Param("url") String url); + + @Query(""" + SELECT new com.sofa.linkiving.domain.link.dto.internal.LinkDto(l, s) + FROM Link l + LEFT JOIN Summary s ON s.link = l AND s.selected = true + WHERE l.id = :id + AND l.member = :member + AND l.isDelete = false + """) + Optional findByIdAndMemberWithSummaryAndIsDeleteFalse( + @Param("id") Long id, + @Param("member") Member member + ); + + @Query(""" + SELECT new com.sofa.linkiving.domain.link.dto.internal.LinkDto(l, s) + FROM Link l + LEFT JOIN Summary s ON s.link = l AND s.selected = true + WHERE l.member = :member + AND l.isDelete = false + AND (:lastId IS NULL OR l.id < :lastId) + ORDER BY l.id DESC + """) + List findAllByMemberWithSummaryAndCursorAndIsDeleteFalse( + @Param("member") Member member, + @Param("lastId") Long lastId, + Pageable pageable + ); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java index 01dfa13d..598a22ba 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkQueryService.java @@ -1,11 +1,13 @@ package com.sofa.linkiving.domain.link.service; +import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.repository.LinkRepository; @@ -28,8 +30,23 @@ public Link findById(Long linkId, Member member) { .orElseThrow(() -> new BusinessException(LinkErrorCode.LINK_NOT_FOUND)); } - public Page findAllByMember(Member member, Pageable pageable) { - return linkRepository.findByMemberAndIsDeleteFalse(member, pageable); + public LinkDto findByIdWithSummary(Long linkId, Member member) { + return linkRepository.findByIdAndMemberWithSummaryAndIsDeleteFalse(linkId, member) + .orElseThrow(() -> new BusinessException(LinkErrorCode.LINK_NOT_FOUND)); + } + + public LinksDto findAllByMemberWithSummaryAndCursor(Member member, Long lastId, int size) { + PageRequest pageRequest = PageRequest.of(0, size + 1); + List linkDtos = linkRepository.findAllByMemberWithSummaryAndCursorAndIsDeleteFalse(member, lastId, + pageRequest); + + boolean hasNext = false; + if (linkDtos.size() > size) { + hasNext = true; + linkDtos.remove(size); + } + + return new LinksDto(linkDtos, hasNext); } public boolean existsByUrl(Member member, String url) { diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java index f6b2fd87..222a8829 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java @@ -3,10 +3,10 @@ import java.util.Optional; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.event.LinkCreatedEvent; @@ -76,8 +76,12 @@ public Link getLink(Long linkId, Member member) { return linkQueryService.findById(linkId, member); } - public Page getLinkList(Member member, Pageable pageable) { - return linkQueryService.findAllByMember(member, pageable); + public LinkDto getLinkWithSummary(Long linkId, Member member) { + return linkQueryService.findByIdWithSummary(linkId, member); + } + + public LinksDto getLinksWithSummary(Member member, Long lastId, int size) { + return linkQueryService.findAllByMemberWithSummaryAndCursor(member, lastId, size); } public Optional findLinkIdByUrl(Member member, String url) { diff --git a/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java index 8459080e..474521c1 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java +++ b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java @@ -6,7 +6,7 @@ import org.jsoup.nodes.Document; import org.springframework.stereotype.Component; -import com.sofa.linkiving.domain.link.dto.OgTagDto; +import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/com/sofa/linkiving/domain/link/entity/SummaryTest.java b/src/test/java/com/sofa/linkiving/domain/link/entity/SummaryTest.java index 711c052c..624145d9 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/entity/SummaryTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/entity/SummaryTest.java @@ -55,7 +55,7 @@ void shouldCreateSummaryWithAllFields() { Format format = Format.DETAILED; String content = "This is a detailed summary"; - String select = "Selected text from the page"; + boolean select = true; Summary summary = Summary.builder() .link(link) @@ -67,6 +67,5 @@ void shouldCreateSummaryWithAllFields() { assertThat(summary.getLink()).isEqualTo(link); assertThat(summary.getContent()).isEqualTo(content); assertThat(summary.getFormat()).isEqualTo(format); - assertThat(summary.getSelected()).isEqualTo(select); } } 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 e64c161f..569bae1f 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 @@ -14,12 +14,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import com.sofa.linkiving.domain.link.dto.OgTagDto; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; +import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; +import com.sofa.linkiving.domain.link.dto.response.LinkCardsRes; import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.entity.Link; @@ -94,24 +93,40 @@ void shouldReturnMetaScrapeResWhenCrawlSucceeds() { void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { // given Long linkId = 1L; - Member member = mock(Member.class); + Member member = mock(Member.class); // Member 객체 Mock + Format format = Format.DETAILED; String url = "https://example.com"; - Link link = mock(Link.class); + String existingSummaryBody = "기존 요약 내용입니다."; + String newSummaryBody = "새로운 상세 요약 내용입니다."; + String comparisonBody = "기존 대비 상세 내용이 추가되었습니다."; - given(link.getUrl()).willReturn(url); - given(linkService.getLink(linkId, member)).willReturn(link); // Service Mock 동작 정의 + // 1. LinkService Mocking (URL 가져오기) + Link mockLink = mock(Link.class); + given(mockLink.getUrl()).willReturn(url); + given(linkService.getLink(linkId, member)).willReturn(mockLink); + // 2. SummaryService (기존 요약 가져오기) Summary mockSummary = mock(Summary.class); - given(mockSummary.getContent()).willReturn("Old"); + given(mockSummary.getContent()).willReturn(existingSummaryBody); given(summaryService.getSummary(linkId)).willReturn(mockSummary); - given(summaryService.createSummary(linkId, url, Format.DETAILED)).willReturn("New"); - given(summaryService.comparisonSummary("Old", "New")).willReturn("Diff"); + + // 3. SummaryService (새 요약 생성 및 비교) + given(summaryService.createSummary(linkId, url, format)).willReturn(newSummaryBody); + given(summaryService.comparisonSummary(existingSummaryBody, newSummaryBody)).willReturn(comparisonBody); // when - RecreateSummaryResponse res = linkFacade.recreateSummary(member, linkId, Format.DETAILED); + RecreateSummaryResponse response = linkFacade.recreateSummary(member, linkId, format); // then - assertThat(res.newSummary()).isEqualTo("New"); + assertThat(response).isNotNull(); + assertThat(response.existingSummary()).isEqualTo(existingSummaryBody); + assertThat(response.newSummary()).isEqualTo(newSummaryBody); + assertThat(response.comparison()).isEqualTo(comparisonBody); + + // verify + verify(summaryService).getSummary(linkId); + verify(summaryService).createSummary(linkId, url, format); + verify(summaryService).comparisonSummary(existingSummaryBody, newSummaryBody); } @Test @@ -267,23 +282,85 @@ void shouldGetLink() { } @Test - @DisplayName("링크 Entity 페이지 목록을 조회할 수 있다") - void shouldGetLinkList() { + @DisplayName("링크 카드 목록을 조회하고 Response DTO로 변환한다 (페이징 포함)") + void shouldGetLinkCards() { // given - Member member = Member.builder().email("test@example.com").build(); - Link link1 = Link.builder().member(member).title("1").build(); - Link link2 = Link.builder().member(member).title("2").build(); - Pageable pageable = PageRequest.of(0, 10); - Page expectedPage = new PageImpl<>(List.of(link1, link2)); + Member member = mock(Member.class); + int size = 10; + + Link link1 = Link.builder() + .member(member) + .url("https://url1.com") + .title("Title1") + .imageUrl("img1.jpg") + .build(); + Summary summary1 = Summary.builder() + .link(link1) + .content("Summary1") + .build(); + + Link link2 = Link.builder() + .member(member) + .url("https://url2.com") + .title("Title2") + .imageUrl("img2.jpg") + .build(); + Summary summary2 = Summary.builder() + .link(link2) + .content("Summary2") + .build(); + + Link.builder() + .member(member) + .url("https://url3.com") + .title("Title3") + .imageUrl("img3.jpg") + .build(); + + LinkDto dto1 = new LinkDto(link1, summary1); + LinkDto dto2 = new LinkDto(link2, summary2); + + LinksDto linksDto = new LinksDto(List.of(dto1, dto2), true); + + given(linkService.getLinksWithSummary(member, null, size)).willReturn(linksDto); + + // when + LinkCardsRes result = linkFacade.getLinkCards(member, null, size); + + // then + assertThat(result).isNotNull(); + + assertThat(result.hasNext()).isTrue(); + + assertThat(result.links()).hasSize(2); + + assertThat(result.links().get(0).title()).isEqualTo("Title1"); + assertThat(result.links().get(0).summary()).isEqualTo("Summary1"); + + assertThat(result.links().get(1).title()).isEqualTo("Title2"); + assertThat(result.links().get(1).summary()).isEqualTo("Summary2"); + } + + @Test + @DisplayName("링크 목록이 비어있을 경우 lastId는 null을 반환한다") + void shouldReturnNullLastIdWhenListIsEmpty() { + // given + Member member = mock(Member.class); + Long lastId = null; + int size = 10; + + // 빈 리스트 반환 설정 + LinksDto emptyLinksDto = new LinksDto(List.of(), false); - given(linkQueryService.findAllByMember(member, pageable)).willReturn(expectedPage); + given(linkService.getLinksWithSummary(member, lastId, size)).willReturn(emptyLinksDto); // when - Page result = linkService.getLinkList(member, pageable); + LinkCardsRes result = linkFacade.getLinkCards(member, lastId, size); // then - assertThat(result).hasSize(2); - assertThat(result.getContent().get(0)).isInstanceOf(Link.class); + assertThat(result.links()).isEmpty(); + assertThat(result.hasNext()).isFalse(); + assertThat(result.lastId()).isNull(); } @Test 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 a4ad7472..d1a52720 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 @@ -142,7 +142,7 @@ void shouldFailWhenDuplicateUrl() throws Exception { } @Test - @DisplayName("링크 조회 성공 시 200 OK 응답") + @DisplayName("링크 단건 조회 성공 시 요약 정보가 포함된 200 OK 응답") void shouldGetLinkSuccessfully() throws Exception { // given Link link = linkRepository.save(Link.builder() @@ -152,6 +152,13 @@ void shouldGetLinkSuccessfully() throws Exception { .memo("테스트 메모") .build()); + summaryRepository.save(Summary.builder() + .link(link) + .content("이것은 요약 내용입니다.") + .format(Format.CONCISE) + .select(true) + .build()); + // when & then mockMvc.perform( get(BASE_URL + "/{id}", link.getId()) @@ -161,9 +168,12 @@ void shouldGetLinkSuccessfully() throws Exception { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(link.getId())) .andExpect(jsonPath("$.data.url").value(link.getUrl())) .andExpect(jsonPath("$.data.title").value(link.getTitle())) - .andExpect(jsonPath("$.data.memo").value(link.getMemo())); + .andExpect(jsonPath("$.data.memo").value(link.getMemo())) + .andExpect(jsonPath("$.data.summary").exists()) + .andExpect(jsonPath("$.data.summary.content").value("이것은 요약 내용입니다.")); } @Test @@ -292,35 +302,46 @@ void shouldReturnFalseWhenUrlDoesNotExist() throws Exception { } @Test - @DisplayName("링크 목록 조회 성공 시 페이징된 결과 반환") + @DisplayName("링크 목록 조회 성공 시 커서 기반 페이징된 결과 반환") void shouldGetLinkListSuccessfully() throws Exception { // given - linkRepository.save(Link.builder() + Link link1 = linkRepository.save(Link.builder() .member(testMember) .url("https://example1.com") .title("링크 1") .build()); - linkRepository.save(Link.builder() + Link link2 = linkRepository.save(Link.builder() .member(testMember) .url("https://example2.com") .title("링크 2") .build()); + summaryRepository.save(Summary.builder() + .link(link2) + .content("링크 2 요약") + .format(Format.CONCISE) + .select(true) + .build()); + // when & then mockMvc.perform( get(BASE_URL) - .param("page", "0") - .param("size", "20") + .param("size", "10") .with(csrf()) .with(user(testUserDetails)) .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.content").isArray()) - .andExpect(jsonPath("$.data.content.length()").value(2)) - .andExpect(jsonPath("$.data.totalElements").value(2)); + .andExpect(jsonPath("$.data.links").isArray()) + .andExpect(jsonPath("$.data.links.length()").value(2)) + .andExpect(jsonPath("$.data.links[0].title").value("링크 2")) + .andExpect(jsonPath("$.data.links[0].summary").value("링크 2 요약")) + .andExpect(jsonPath("$.data.links[1].title").value("링크 1")) + .andExpect(jsonPath("$.data.links[1].summary").isEmpty()) // 링크 1은 요약 없음 + .andExpect(jsonPath("$.data.hasNext").value(false)) // 2개 조회, size 10이므로 다음 없음 + .andExpect(jsonPath("$.data.lastId").value(link1.getId())); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/link/repository/LinkRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/link/repository/LinkRepositoryTest.java index 37f769cf..c1c7824f 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/repository/LinkRepositoryTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/repository/LinkRepositoryTest.java @@ -2,16 +2,21 @@ import static org.assertj.core.api.Assertions.*; +import java.util.List; +import java.util.Optional; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.test.context.ActiveProfiles; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; 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; import jakarta.persistence.EntityManager; @@ -81,32 +86,6 @@ void shouldFindByIdAndMember() { assertThat(foundLink.getUrl()).isEqualTo("https://example.com"); } - @Test - @DisplayName("멤버의 링크 목록을 페이징 조회할 수 있다") - void shouldFindByMemberWithPaging() { - // given - Link link1 = Link.builder() - .member(testMember) - .url("https://example1.com") - .title("링크 1") - .build(); - Link link2 = Link.builder() - .member(testMember) - .url("https://example2.com") - .title("링크 2") - .build(); - linkRepository.save(link1); - linkRepository.save(link2); - - // when - Page links = linkRepository.findByMemberAndIsDeleteFalse( - testMember, PageRequest.of(0, 10)); - - // then - assertThat(links.getTotalElements()).isEqualTo(2); - assertThat(links.getContent()).hasSize(2); - } - @Test @DisplayName("URL 중복을 체크할 수 있다") void shouldCheckUrlDuplication() { @@ -130,24 +109,93 @@ void shouldCheckUrlDuplication() { } @Test - @DisplayName("삭제된 링크는 조회되지 않는다") - void shouldNotFindDeletedLink() { + @DisplayName("ID로 링크와 선택된 요약 정보(LinkDto)를 함께 조회할 수 있다") + void shouldFindByIdAndMemberWithSummary() { // given Link link = Link.builder() .member(testMember) - .url("https://example.com") - .title("테스트 링크") + .url("https://summary-test.com") + .title("요약 테스트 링크") .build(); - Link savedLink = linkRepository.save(link); + entityManager.persist(link); + + Summary selectedSummary = Summary.builder() + .link(link) + .content("선택된 요약 내용") + .format(Format.CONCISE) + .select(true) + .build(); + entityManager.persist(selectedSummary); + + Summary otherSummary = Summary.builder() + .link(link) + .content("다른 요약") + .format(Format.CONCISE) + .select(false) + .build(); + entityManager.persist(otherSummary); - savedLink.markDeleted(); - linkRepository.save(savedLink); + entityManager.flush(); + entityManager.clear(); // when - Page links = linkRepository.findByMemberAndIsDeleteFalse( - testMember, PageRequest.of(0, 10)); + Optional result = linkRepository.findByIdAndMemberWithSummaryAndIsDeleteFalse(link.getId(), + testMember); // then - assertThat(links.getTotalElements()).isEqualTo(0); + assertThat(result).isPresent(); + assertThat(result.get().link().getUrl()).isEqualTo("https://summary-test.com"); + assertThat(result.get().summary()).isNotNull(); + assertThat(result.get().summary().getContent()).isEqualTo("선택된 요약 내용"); + } + + @Test + @DisplayName("링크 목록을 요약 정보와 함께 커서 기반으로 조회할 수 있다") + void shouldFindAllByMemberWithSummaryAndCursor() { + // given + for (int i = 1; i <= 3; i++) { + Link link = Link.builder() + .member(testMember) + .url("https://paging-" + i + ".com") + .title("링크 " + i) + .build(); + entityManager.persist(link); + + // 짝수 번째 링크에만 요약 추가 + if (i % 2 == 0) { + Summary summary = Summary.builder() + .link(link) + .content("요약 " + i) + .format(Format.CONCISE) + .select(true) + .build(); + entityManager.persist(summary); + } + } + entityManager.flush(); + entityManager.clear(); + + // when 1 + List page1 = linkRepository.findAllByMemberWithSummaryAndCursorAndIsDeleteFalse( + testMember, null, PageRequest.of(0, 2)); + + // then 1 + assertThat(page1).hasSize(2); + assertThat(page1.get(0).link().getTitle()).isEqualTo("링크 3"); + assertThat(page1.get(0).summary()).isNull(); // 요약 없음 + + assertThat(page1.get(1).link().getTitle()).isEqualTo("링크 2"); + assertThat(page1.get(1).summary()).isNotNull(); // 요약 있음 + assertThat(page1.get(1).summary().getContent()).isEqualTo("요약 2"); + + Long lastId = page1.get(1).link().getId(); + + // when 2 - 다음 내용 + List page2 = linkRepository.findAllByMemberWithSummaryAndCursorAndIsDeleteFalse( + testMember, lastId, PageRequest.of(0, 2)); + + // then 2 + assertThat(page2).hasSize(1); + assertThat(page2.get(0).link().getTitle()).isEqualTo("링크 1"); } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkQueryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkQueryServiceTest.java index 00423f27..bcdae2d9 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkQueryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkQueryServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -12,12 +13,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.repository.LinkRepository; import com.sofa.linkiving.domain.member.entity.Member; @@ -77,58 +78,113 @@ void shouldThrowExceptionWhenLinkNotFound() { } @Test - @DisplayName("멤버의 모든 링크를 페이징 조회할 수 있다") - void shouldFindAllByMember() { + @DisplayName("URL 중복 여부를 확인할 수 있다") + void shouldCheckUrlExists() { // given Member member = Member.builder() .email("test@example.com") .password("password") .build(); - Link link1 = Link.builder() - .member(member) - .url("https://example1.com") - .title("링크 1") - .build(); + given(linkRepository.existsByMemberAndUrlAndIsDeleteFalse(member, "https://example.com")) + .willReturn(true); - Link link2 = Link.builder() - .member(member) - .url("https://example2.com") - .title("링크 2") - .build(); + // when + boolean exists = linkQueryService.existsByUrl(member, "https://example.com"); + + // then + assertThat(exists).isTrue(); + verify(linkRepository, times(1)) + .existsByMemberAndUrlAndIsDeleteFalse(member, "https://example.com"); + } - Pageable pageable = PageRequest.of(0, 10); - Page expectedPage = new PageImpl<>(List.of(link1, link2)); + @Test + @DisplayName("커서 기반 목록 조회 시 요청 개수보다 데이터가 많으면 hasNext=true를 반환함") + void shouldFindAllByMemberWithSummaryAndCursor_HasNextTrue() { + // given + Member member = mock(Member.class); + Long lastId = 100L; + int size = 10; + + // Repository가 size + 1 (11개) 데이터를 반환한다고 가정 + List dtos = new ArrayList<>(); + for (int i = 0; i < size + 1; i++) { + dtos.add(mock(LinkDto.class)); + } - given(linkRepository.findByMemberAndIsDeleteFalse(member, pageable)).willReturn(expectedPage); + // Pageable 검증 (size + 1 로 요청했는지) + given(linkRepository.findAllByMemberWithSummaryAndCursorAndIsDeleteFalse( + eq(member), eq(lastId), any(Pageable.class))) + .willReturn(dtos); // when - Page result = linkQueryService.findAllByMember(member, pageable); + LinksDto result = linkQueryService.findAllByMemberWithSummaryAndCursor(member, lastId, size); // then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(2); - verify(linkRepository, times(1)).findByMemberAndIsDeleteFalse(member, pageable); + assertThat(result.hasNext()).isTrue(); + assertThat(result.linkDtos()).hasSize(size); // 11개 -> 10개로 잘림 } @Test - @DisplayName("URL 중복 여부를 확인할 수 있다") - void shouldCheckUrlExists() { + @DisplayName("커서 기반 목록 조회 시 데이터가 요청 개수 이하이면 hasNext=false를 반환함") + void shouldFindAllByMemberWithSummaryAndCursor_HasNextFalse() { // given - Member member = Member.builder() - .email("test@example.com") - .password("password") - .build(); + Member member = mock(Member.class); + Long lastId = 100L; + int size = 10; - given(linkRepository.existsByMemberAndUrlAndIsDeleteFalse(member, "https://example.com")) - .willReturn(true); + // Repository가 딱 size (10개) 데이터를 반환한다고 가정 + List dtos = new ArrayList<>(); + for (int i = 0; i < size; i++) { + dtos.add(mock(LinkDto.class)); + } + + given(linkRepository.findAllByMemberWithSummaryAndCursorAndIsDeleteFalse( + eq(member), eq(lastId), any(Pageable.class))) + .willReturn(dtos); // when - boolean exists = linkQueryService.existsByUrl(member, "https://example.com"); + LinksDto result = linkQueryService.findAllByMemberWithSummaryAndCursor(member, lastId, size); // then - assertThat(exists).isTrue(); - verify(linkRepository, times(1)) - .existsByMemberAndUrlAndIsDeleteFalse(member, "https://example.com"); + assertThat(result.hasNext()).isFalse(); + assertThat(result.linkDtos()).hasSize(size); // 그대로 10개 + } + + @Test + @DisplayName("ID로 링크와 요약 정보(LinkDto)를 조회할 수 있다") + void shouldFindByIdWithSummary() { + // given + Member member = mock(Member.class); + Link link = mock(Link.class); + Summary summary = mock(Summary.class); + + LinkDto expectedDto = new LinkDto(link, summary); + + given(linkRepository.findByIdAndMemberWithSummaryAndIsDeleteFalse(1L, member)) + .willReturn(Optional.of(expectedDto)); + + // when + LinkDto result = linkQueryService.findByIdWithSummary(1L, member); + + // then + assertThat(result).isEqualTo(expectedDto); + verify(linkRepository).findByIdAndMemberWithSummaryAndIsDeleteFalse(1L, member); + } + + @Test + @DisplayName("요약 포함 조회 시 존재하지 않거나 삭제된 링크면 예외가 발생한다") + void shouldThrowExceptionWhenLinkNotFoundInFindByIdWithSummary() { + // given + Member member = mock(Member.class); + Long linkId = 999L; + + given(linkRepository.findByIdAndMemberWithSummaryAndIsDeleteFalse(linkId, member)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> linkQueryService.findByIdWithSummary(linkId, member)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.LINK_NOT_FOUND); } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java index ea5d64ae..202b04e7 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java @@ -14,12 +14,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.dto.internal.LinksDto; +import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; +import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -263,38 +264,59 @@ void shouldGetLink() { } @Test - @DisplayName("링크 목록을 조회할 수 있다") - void shouldGetLinkList() { + @DisplayName("링크 (요약 포함) 단건 조회 시 LinkDto를 반환함") + void shouldGetLinkWithSummary() { // given - Member member = Member.builder() - .email("test@example.com") - .password("password") - .build(); + Member member = mock(Member.class); - Link link1 = Link.builder() - .member(member) - .url("https://example1.com") - .title("링크 1") - .build(); + // Link Mock + Link link = mock(Link.class); - Link link2 = Link.builder() - .member(member) - .url("https://example2.com") - .title("링크 2") - .build(); + Summary summary = mock(Summary.class); - Pageable pageable = PageRequest.of(0, 10); - Page expectedPage = new PageImpl<>(List.of(link1, link2)); + LinkDto linkDto = new LinkDto(link, summary); - given(linkQueryService.findAllByMember(member, pageable)).willReturn(expectedPage); + given(linkQueryService.findByIdWithSummary(1L, member)).willReturn(linkDto); // when - Page result = linkService.getLinkList(member, pageable); + LinkDto result = linkService.getLinkWithSummary(1L, member); // then assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(2); - verify(linkQueryService, times(1)).findAllByMember(member, pageable); + assertThat(result.link()).isEqualTo(link); + assertThat(result.summary()).isEqualTo(summary); + + verify(linkQueryService).findByIdWithSummary(1L, member); + } + + @Test + @DisplayName("링크 목록 조회 시 커서 기반 페이징된 LinksRes를 반환함") + void shouldGetLinksWithSummary() { + // given + Member member = mock(Member.class); + Long lastId = 10L; + int size = 5; + + // Link & LinkDto Mock + Link link = mock(Link.class); + + LinkDto linkDto = new LinkDto(link, null); // 요약 없음 가정 + List dtos = List.of(linkDto); + + LinksDto linksDto = new LinksDto(dtos, true); // 다음 페이지 있음 + + given(linkQueryService.findAllByMemberWithSummaryAndCursor(member, lastId, size)) + .willReturn(linksDto); + + // when + LinksDto result = linkService.getLinksWithSummary(member, lastId, size); + + // then + assertThat(result).isNotNull(); + assertThat(result.linkDtos()).hasSize(1); + assertThat(result.hasNext()).isTrue(); + + verify(linkQueryService).findAllByMemberWithSummaryAndCursor(member, lastId, size); } @Test