diff --git a/build.gradle b/build.gradle index 40baffc0..06110c0c 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,9 @@ dependencies { // mybatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4' + // markdown parser + implementation 'org.commonmark:commonmark:0.21.0' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' diff --git a/src/docs/asciidoc/api/pick/pick-main-v2.adoc b/src/docs/asciidoc/api/pick/pick-main-v2.adoc new file mode 100644 index 00000000..a26f54b4 --- /dev/null +++ b/src/docs/asciidoc/api/pick/pick-main-v2.adoc @@ -0,0 +1,17 @@ +[[Pick-Main-V2]] +== 픽픽픽 메인 API(GET: /devdevdev/api/v2/picks) +* 픽픽픽 메인 화면을 조회할 수 있다. +* 회원/익명 사용자에 따라 API 응답값이 상이하다. + +=== 정상 요청/응답 +==== HTTP Request +include::{snippets}/pick-main-v2/http-request.adoc[] +==== HTTP Request Header Fields +include::{snippets}/pick-main-v2/request-headers.adoc[] +==== HTTP Request Query Parameters Fields +include::{snippets}/pick-main-v2/query-parameters.adoc[] + +==== HTTP Response +include::{snippets}/pick-main-v2/http-response.adoc[] +==== HTTP Response Fields +include::{snippets}/pick-main-v2/response-fields.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc b/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc new file mode 100644 index 00000000..f83b17c8 --- /dev/null +++ b/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc @@ -0,0 +1,38 @@ +[[Pick-Similarties-V2]] +== 나도 고민 했는데 픽픽픽 API(GET: /devdevdev/api/v2/picks/{pickId}/similarties) + +* 픽픽픽 상세와 유사도가 가장 높은 픽픽픽 3개를 조회한다. +* 유사도가 가장 높은 픽픽픽이 존재하지 않으면 빈 배열로 응답된다. +* 유사도 계산에 사용되는 embeddings 값이 없을 경우 503 예외를 발생한다. +** 외부 API(Open AI API) 를 사용하기 때문에 embeddings 값을 저장하지 못할 수 있다. +* 회원/익명 사용자 모두 조회할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/pick-similarity-v2/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/pick-similarity-v2/request-headers.adoc[] + +==== HTTP Request Path Parameters Fields + +include::{snippets}/pick-similarity-v2/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/pick-similarity-v2/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/pick-similarity-v2/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +include::{snippets}/pick-similarity-not-found/response-body.adoc[] +include::{snippets}/pick-similarity-bad-request/response-body.adoc[] +include::{snippets}/pick-similarity-internal-server-exception/response-body.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick.adoc b/src/docs/asciidoc/api/pick/pick.adoc index f28862e0..3bfcca27 100644 --- a/src/docs/asciidoc/api/pick/pick.adoc +++ b/src/docs/asciidoc/api/pick/pick.adoc @@ -1,6 +1,7 @@ = 픽픽픽 include::pick-main.adoc[] +include::pick-main-v2.adoc[] include::pick-detail.adoc[] include::pick-register.adoc[] include::pick-modify.adoc[] @@ -8,4 +9,5 @@ include::pick-delete.adoc[] include::pick-vote.adoc[] include::pick-option-register-image.adoc[] include::pick-option-delete-image.adoc[] -include::pick-similarity.adoc[] \ No newline at end of file +include::pick-similarity.adoc[] +include::pick-similarity-v2.adoc[] \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java index 1ff6838b..e8129d1a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java @@ -17,4 +17,6 @@ public interface PickRepository extends JpaRepository, PickRepositor List findTop1000ByContentStatusAndEmbeddingsIsNotNullOrderByCreatedAtDesc(ContentStatus contentStatus); Long countByMember(Member member); + + Long countByContentStatus(ContentStatus contentStatus); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index e24f1682..71a6f8e5 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -1,16 +1,18 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.repository.pick.*; +import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -22,30 +24,52 @@ @Service @Transactional(readOnly = true) public class GuestPickServiceV2 extends PickCommonService implements PickServiceV2 { - public GuestPickServiceV2(EmbeddingsService embeddingsService, + + private final AnonymousMemberService anonymousMemberService; + + public GuestPickServiceV2(PickRepository pickRepository, EmbeddingsService embeddingsService, PickBestCommentsPolicy pickBestCommentsPolicy, - PickPopularScorePolicy pickPopularScorePolicy, - TimeProvider timeProvider, - PickRepository pickRepository, PickCommentRepository pickCommentRepository, - PickCommentRecommendRepository pickCommentRecommendRepository) { + PickCommentRecommendRepository pickCommentRecommendRepository, + PickPopularScorePolicy pickPopularScorePolicy, + TimeProvider timeProvider, AnonymousMemberService anonymousMemberService) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, pickCommentRepository, pickCommentRecommendRepository); + this.anonymousMemberService = anonymousMemberService; } + /** + * 픽픽픽 메인 조회 V2 + */ + @Transactional @Override - public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { - return null; - } + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, + String anonymousMemberId, Authentication authentication) { + // 익명 사용자 호출인지 확인 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); - @Override - public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { - return null; + // anonymousMemberId 검증 + AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); + + // 픽픽픽 조회 + Slice picks = pickRepository.findPicksByCursor(pageable, pickId, pickSort); + + // 데이터 가공 + List pickMainResponse = picks.stream() + .map(pick -> PickMainResponseV2.of(pick, anonymousMember)) + .toList(); + + // 전체 픽픽픽 개수 조회 + long totalElements = pickRepository.countByContentStatus(ContentStatus.APPROVAL); + + return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 나도 고민했는데 픽픽픽 V2 + */ @Override - public List findTop3SimilarPicks(Long pickId) { - return null; + public List findTop3SimilarPicksV2(Long pickId) { + return super.findTop3SimilarPicksV2(pickId); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java index 5102de41..eda4719d 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -1,16 +1,20 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -23,30 +27,48 @@ @Transactional(readOnly = true) public class MemberPickServiceV2 extends PickCommonService implements PickServiceV2 { - public MemberPickServiceV2(EmbeddingsService embeddingsService, - PickBestCommentsPolicy pickBestCommentsPolicy, - PickPopularScorePolicy pickPopularScorePolicy, - TimeProvider timeProvider, - PickRepository pickRepository, + private final MemberProvider memberProvider; + + public MemberPickServiceV2(EmbeddingsService embeddingsService, PickRepository pickRepository, + MemberProvider memberProvider, PickCommentRepository pickCommentRepository, - PickCommentRecommendRepository pickCommentRecommendRepository) { + PickCommentRecommendRepository pickCommentRecommendRepository, + PickPopularScorePolicy pickPopularScorePolicy, + PickBestCommentsPolicy pickBestCommentsPolicy, TimeProvider timeProvider) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, - pickCommentRepository, pickCommentRecommendRepository); + pickCommentRepository, + pickCommentRecommendRepository); + this.memberProvider = memberProvider; } + /** + * 픽픽픽 메인 조회 V2 + */ @Override - public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { - return null; - } + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, + String anonymousMemberId, Authentication authentication) { + // 픽픽픽 조회 + Slice picks = pickRepository.findPicksByCursor(pageable, pickId, pickSort); - @Override - public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { - return null; + // 회원 조회 + Member member = memberProvider.getMemberByAuthentication(authentication); + + // 데이터 가공 + List pickMainResponse = picks.stream() + .map(pick -> PickMainResponseV2.of(pick, member)) + .toList(); + + // 전체 픽픽픽 개수 조회 + long totalElements = pickRepository.countByContentStatus(ContentStatus.APPROVAL); + + return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 나도 고민했는데 픽픽픽 V2 + */ @Override - public List findTop3SimilarPicks(Long pickId) { - return null; + public List findTop3SimilarPicksV2(Long pickId) { + return super.findTop3SimilarPicksV2(pickId); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java index 2ee55318..8fcc1d0b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java @@ -36,6 +36,8 @@ import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; + +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -86,6 +88,33 @@ public List findTop3SimilarPicks(Long pickId) { .toList(); } + public List findTop3SimilarPicksV2(Long pickId) { + + // 픽픽픽 조회 + Pick findPick = pickRepository.findById(pickId) + .orElseThrow(() -> new NotFoundException(INVALID_NOT_FOUND_PICK_MESSAGE)); + + // 픽픽픽 게시글의 승인 상태가 아니면 + if (!findPick.isTrueContentStatus(ContentStatus.APPROVAL)) { + throw new IllegalArgumentException(INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE); + } + + // 임베딩 값이 없으면 500 예외 발생 + if (ObjectUtils.isEmpty(findPick.getEmbeddings())) { + throw new InternalServerException(); + } + + // 유사도를 계산한 픽픽픽 조회 + List pickWithSimilarityDto = embeddingsService.getPicksWithSimilarityDtoExcludeTargetPick( + findPick); + + return pickWithSimilarityDto.stream() + .map(SimilarPickResponseV2::from) + .sorted(Comparator.comparingDouble(SimilarPickResponseV2::getSimilarity).reversed()) // 내림차순 + .limit(SIMILARITY_PICK_MAX_COUNT) + .toList(); + } + // 픽픽픽 게시글의 승인 상태 검증 public static void validateIsApprovalPickContentStatus(Pick pick, String message, @Nullable String messageArgs) { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java index 1d0b2818..a9be0ccc 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java @@ -1,9 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -11,10 +10,8 @@ import java.util.List; public interface PickServiceV2 extends PickService { - Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, - Authentication authentication); + Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, + Authentication authentication); - PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); - - List findTop3SimilarPicks(Long pickId); + List findTop3SimilarPicksV2(Long pickId); } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java new file mode 100644 index 00000000..3c9b7427 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java @@ -0,0 +1,27 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.text.TextContentRenderer; + +/** + * 마크다운 처리를 위한 유틸리티 클래스 + */ +public abstract class MarkdownUtils { + + private static final Parser PARSER = Parser.builder().build(); + private static final TextContentRenderer TEXT_RENDERER = TextContentRenderer.builder().build(); + + /** + * 마크다운 텍스트를 순수 텍스트로 변환 + */ + public static String convertMarkdownToText(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return markdown; + } + + Node document = PARSER.parse(markdown); + return TEXT_RENDERER.render(document).trim(); + } +} + diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java new file mode 100644 index 00000000..003a9c51 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java @@ -0,0 +1,22 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 시간 관련 유틸리티 클래스 + */ +public abstract class TimeUtils { + + /** + * 주어진 날짜가 현재로부터 일주일 이내인지 확인합니다. + * + * @param createdAt 확인할 시간 + * @return 날짜 기준 일주일 이내라면 true, 그렇지 않다면 false + */ + public static boolean isWithinOneWeek(LocalDateTime createdAt, LocalDateTime now) { + LocalDate createdDate = createdAt.toLocalDate(); + LocalDate oneWeekAgo = now.toLocalDate().minusWeeks(1); + return !createdDate.isBefore(oneWeekAgo); + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java index ef08db98..d374e216 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java @@ -1,17 +1,14 @@ package com.dreamypatisiel.devdevdev.web.controller.pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickFacadeService; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.global.utils.HttpRequestUtils; -import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -27,7 +24,7 @@ import static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; -@Tag(name = "픽픽픽 API", description = "") +@Tag(name = "픽픽픽 API V2", description = "") @RestController @RequiredArgsConstructor @RequestMapping("/devdevdev/api/v2") @@ -35,9 +32,9 @@ public class PickControllerV2 { private final PickServiceStrategy pickServiceStrategy; - @Operation(summary = "픽픽픽 메인 조회", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") + @Operation(summary = "픽픽픽 메인 조회 V2", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") @GetMapping("/picks") - public ResponseEntity>> getPicksMain( + public ResponseEntity>> getPicksMain( @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable, @RequestParam(required = false) Long pickId, @RequestParam(required = false) PickSort pickSort) { @@ -46,30 +43,18 @@ public ResponseEntity>> getPicksMain( String anonymousMemberId = HttpRequestUtils.getHeaderValue(HEADER_ANONYMOUS_MEMBER_ID); PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, + Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, authentication); return ResponseEntity.ok(BasicResponse.success(response)); } - @Operation(summary = "픽픽픽 상세 조회", description = "픽픽픽 상세 페이지를 조회합니다.") - @GetMapping("/picks/{pickId}") - public ResponseEntity> getPickDetail(@PathVariable Long pickId, - @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { - Authentication authentication = AuthenticationMemberUtils.getAuthentication(); - - PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - PickDetailResponse response = pickService.findPickDetail(pickId, anonymousMemberId, authentication); - - return ResponseEntity.ok(BasicResponse.success(response)); - } - - @Operation(summary = "나도 고민했는데 픽픽픽", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") + @Operation(summary = "나도 고민했는데 픽픽픽 V2", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") @GetMapping("picks/{pickId}/similarties") - public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { + public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - List response = pickService.findTop3SimilarPicks(pickId); + List response = pickService.findTop3SimilarPicksV2(pickId); return ResponseEntity.ok(BasicResponse.success(response)); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java new file mode 100644 index 00000000..698549ed --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java @@ -0,0 +1,86 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.global.utils.MarkdownUtils; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import lombok.Builder; +import lombok.Data; +import org.springframework.util.ObjectUtils; + +import java.math.BigDecimal; +import java.util.List; + +@Data +public class PickMainOptionResponseV2 { + private final Long id; + private final String title; + private final int percent; + private final Boolean isPicked; + private final String content; + private final String thumbnailImageUrl; + + @Builder + public PickMainOptionResponseV2(Long id, Title title, BigDecimal percent, Boolean isPicked, + String content, String thumbnailImageUrl) { + this.id = id; + this.title = title.getTitle(); + this.percent = percent.intValueExact(); + this.isPicked = isPicked; + this.content = content; + this.thumbnailImageUrl = thumbnailImageUrl; + } + + // 회원 전용 + public static PickMainOptionResponseV2 of(Pick pick, PickOption pickOption, Member member) { + return PickMainOptionResponseV2.builder() + .id(pickOption.getId()) + .title(pickOption.getTitle()) + .percent(PickOption.calculatePercentBy(pick, pickOption)) + .isPicked(PickResponseUtils.isPickedPickOptionByMember(pick, pickOption, member)) + .content(getContentWithLengthLimit(pickOption)) + .thumbnailImageUrl(getThumbnailImageUrl(pickOption)) + .build(); + } + + // 익명 회원 전용 + public static PickMainOptionResponseV2 of(Pick pick, PickOption pickOption, AnonymousMember anonymousMember) { + return PickMainOptionResponseV2.builder() + .id(pickOption.getId()) + .title(pickOption.getTitle()) + .percent(PickOption.calculatePercentBy(pick, pickOption)) + .isPicked(PickResponseUtils.isPickedPickOptionByAnonymousMember(pick, pickOption, anonymousMember)) + .content(getContentWithLengthLimit(pickOption)) + .thumbnailImageUrl(getThumbnailImageUrl(pickOption)) + .build(); + } + + public static String getThumbnailImageUrl(PickOption pickOption) { + List imageUrls = pickOption.getPickOptionImages().stream() + .map(PickOptionImage::getImageUrl) + .toList(); + + return imageUrls.isEmpty() ? null : imageUrls.getFirst(); + } + + public static String getContentWithLengthLimit(PickOption pickOption) { + if (pickOption == null || pickOption.getContents() == null) { + return null; + } + + String content = pickOption.getContents().getPickOptionContents(); + if (ObjectUtils.isEmpty(content)) { + return null; + } + + // 마크다운 문법 제거 및 300자 제한 + String text = MarkdownUtils.convertMarkdownToText(content); + + // 300자 제한 + if (text.length() > 300) { + return text.substring(0, 300); + } + return text; + } +} + diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java new file mode 100644 index 00000000..1e7c7b8f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java @@ -0,0 +1,84 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class PickMainResponseV2 { + private Long id; + private String title; + private long voteTotalCount; + private long commentTotalCount; + private long viewTotalCount; + private long popularScore; + private Boolean isVoted; + private Boolean isNew; + private List pickOptions; + + @Builder + public PickMainResponseV2(Long id, Title title, Count voteTotalCount, Count commentTotalCount, + Count viewTotalCount, Count popularScore, Boolean isVoted, Boolean isNew, + List pickOptions) { + this.id = id; + this.title = title.getTitle(); + this.voteTotalCount = voteTotalCount.getCount(); + this.commentTotalCount = commentTotalCount.getCount(); + this.viewTotalCount = viewTotalCount.getCount(); + this.popularScore = popularScore.getCount(); + this.isVoted = isVoted; + this.isNew = isNew; + this.pickOptions = pickOptions; + } + + // 회원 전용 + public static PickMainResponseV2 of(Pick pick, Member member) { + return PickMainResponseV2.builder() + .id(pick.getId()) + .title(pick.getTitle()) + .voteTotalCount(pick.getVoteTotalCount()) + .commentTotalCount(pick.getCommentTotalCount()) + .viewTotalCount(pick.getViewTotalCount()) + .popularScore(pick.getPopularScore()) + .pickOptions(mapToPickOptionsResponse(pick, member)) + .isVoted(PickResponseUtils.isVotedMember(pick, member)) + .isNew(PickResponseUtils.isNewPick(pick)) + .build(); + } + + // 익명 회원 전용 + public static PickMainResponseV2 of(Pick pick, AnonymousMember anonymousMember) { + return PickMainResponseV2.builder() + .id(pick.getId()) + .title(pick.getTitle()) + .voteTotalCount(pick.getVoteTotalCount()) + .commentTotalCount(pick.getCommentTotalCount()) + .viewTotalCount(pick.getViewTotalCount()) + .popularScore(pick.getPopularScore()) + .pickOptions(mapToPickOptionsResponse(pick, anonymousMember)) + .isVoted(PickResponseUtils.isVotedAnonymousMember(pick, anonymousMember)) + .isNew(PickResponseUtils.isNewPick(pick)) + .build(); + } + + private static List mapToPickOptionsResponse(Pick pick, Member member) { + return pick.getPickOptions().stream() + .map(pickOption -> PickMainOptionResponseV2.of(pick, pickOption, member)) + .toList(); + } + + private static List mapToPickOptionsResponse(Pick pick, AnonymousMember anonymousMember) { + return pick.getPickOptions().stream() + .map(pickOption -> PickMainOptionResponseV2.of(pick, pickOption, anonymousMember)) + .toList(); + } + +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java new file mode 100644 index 00000000..056511a2 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java @@ -0,0 +1,19 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import lombok.Builder; +import lombok.Data; +import org.springframework.data.domain.Slice; + +@Data +@Builder +public class PickMainSliceResponseV2 { + private final Slice picks; + private final long totalElements; + + public static PickMainSliceResponseV2 of(Slice picks, long totalElements) { + return PickMainSliceResponseV2.builder() + .picks(picks) + .totalElements(totalElements) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java new file mode 100644 index 00000000..7eb49f2e --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java @@ -0,0 +1,42 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.openai.data.response.PickWithSimilarityDto; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import lombok.Builder; +import lombok.Data; + +@Data +public class SimilarPickResponseV2 { + private final Long id; + private final String title; + private final long voteTotalCount; + private final long commentTotalCount; + private final double similarity; + private final Boolean isNew; + + @Builder + public SimilarPickResponseV2(Long id, String title, long voteTotalCount, long commentTotalCount, + double similarity, Boolean isNew) { + this.id = id; + this.title = title; + this.voteTotalCount = voteTotalCount; + this.commentTotalCount = commentTotalCount; + this.similarity = similarity; + this.isNew = isNew; + } + + public static SimilarPickResponseV2 from(PickWithSimilarityDto pickWithSimilarityDto) { + Pick pick = pickWithSimilarityDto.getPick(); + + return SimilarPickResponseV2.builder() + .id(pick.getId()) + .title(pick.getTitle().getTitle()) + .voteTotalCount(pick.getVoteTotalCount().getCount()) + .commentTotalCount(pick.getCommentTotalCount().getCount()) + .similarity(pickWithSimilarityDto.getSimilarity()) + .isNew(PickResponseUtils.isNewPick(pick)) + .build(); + } + +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java index b2c290aa..634b36b7 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java @@ -4,8 +4,12 @@ import com.dreamypatisiel.devdevdev.domain.entity.Member; import com.dreamypatisiel.devdevdev.domain.entity.Pick; import com.dreamypatisiel.devdevdev.domain.entity.PickOption; +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.global.utils.TimeUtils; -public class PickResponseUtils { +import java.time.LocalDateTime; + +public abstract class PickResponseUtils { public static boolean isVotedMember(Pick pick, Member member) { return pick.getPickVotes().stream() @@ -41,4 +45,8 @@ public static boolean isPickedPickOptionByAnonymousMember(Pick pick, PickOption .anyMatch(pickVote -> !pickVote.isDeleted() && pickVote.getAnonymousMember().isEqualAnonymousMemberId(anonymousMember.getId())); } + + public static boolean isNewPick(Pick pick) { + return TimeUtils.isWithinOneWeek(pick.getCreatedAt(), LocalDateTime.now()); + } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java new file mode 100644 index 00000000..c4c208ae --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java @@ -0,0 +1,275 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionImageRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.firstPickOption; +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.service.pick.PickTestUtils.createPickOptionImage; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@Transactional +class GuestPickServiceV2Test { + + @Autowired + GuestPickServiceV2 guestPickServiceV2; + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + MemberRepository memberRepository; + @MockBean + AnonymousMemberService anonymousMemberService; + @MockBean + EmbeddingsService embeddingsService; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @Test + @DisplayName("익명 회원이 픽픽픽 메인을 조회한다.") + void findPicksMain() { + // given + String anonymousMemberId = "GA1.1.276672604.1715872960"; + AnonymousMember anonymousMember = AnonymousMember.builder() + .anonymousMemberId(anonymousMemberId) + .build(); + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + // 픽픽픽 생성 + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, + ContentStatus.APPROVAL); + pickRepository.save(pick); + + // 픽픽픽 옵션 생성 + PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), + new Count(1), PickOptionType.firstPickOption); + PickOption secondPickOption = createPickOption(pick, new Title("픽픽픽 옵션2"), new PickOptionContents("픽픽픽 옵션2 내용"), + new Count(0), PickOptionType.secondPickOption); + pickOptionRepository.saveAll(List.of(firstPickOption, secondPickOption)); + + // 픽픽픽 옵션 이미지 생성 + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Slice picksMain = guestPickServiceV2.findPicksMain(pageable, null, null, anonymousMemberId, authentication); + + // then + Pick findPick = pickRepository.findById(pick.getId()).get(); + assertThat(picksMain).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(picksMain.getContent().get(0).getPickOptions()).hasSize(2) + .extracting("id", "title", "percent", "isPicked", "content", "thumbnailImageUrl") + .containsExactly( + tuple(pickOptions.get(0).getId(), pickOptions.get(0).getTitle().getTitle(), 100, + false, "픽픽픽 옵션1 내용", "http://iamge1.png"), + tuple(pickOptions.get(1).getId(), pickOptions.get(1).getTitle().getTitle(), 0, + false, "픽픽픽 옵션2 내용", "http://iamge2.png") + ); + } + + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, + Count poplarScore, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .viewTotalCount(viewTotalCount) + .voteTotalCount(voteTotalCount) + .commentTotalCount(commentTotalCount) + .popularScore(poplarScore) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOption createPickOption(Title title, Count voteTotalCount, PickOptionType pickOptionType, Pick pick) { + PickOption pickOption = PickOption.builder() + .title(title) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private PickVote createPickVote(Member member, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .member(member) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private PickVote createPickVote(AnonymousMember anonymousMember, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .anonymousMember(anonymousMember) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private Pick createPick(Title title, Count pickVoteCount, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOptionImage createPickOptionImage(String name, String imageUrl, PickOption pickOption) { + PickOptionImage pickOptionImage = PickOptionImage.builder() + .name(name) + .imageUrl(imageUrl) + .imageKey("imageKey") + .build(); + + pickOptionImage.changePickOption(pickOption); + + return pickOptionImage; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, Count pickPopularScore, String thumbnailUrl, + String author, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .popularScore(pickPopularScore) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, String thumbnailUrl, String author, + List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Pick pick, Title title, PickOptionContents pickOptionContents, + Count voteTotalCount, PickOptionType pickOptionType) { + PickOption pickOption = PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java new file mode 100644 index 00000000..ded5f271 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java @@ -0,0 +1,261 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionImageRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +@SpringBootTest +@Transactional +class MemberPickServiceV2Test { + + @Autowired + MemberPickServiceV2 memberPickServiceV2; + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + PickPopularScorePolicy pickPopularScorePolicy; + @Autowired + EntityManager em; + @MockBean + MemberProvider memberProvider; + @MockBean + EmbeddingsService embeddingsService; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void findPicksMain() { + // given + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + // 픽픽픽 생성 + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, + ContentStatus.APPROVAL); + pickRepository.save(pick); + + // 픽픽픽 옵션 생성 + PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), + new Count(1), PickOptionType.firstPickOption); + PickOption secondPickOption = createPickOption(pick, new Title("픽픽픽 옵션2"), new PickOptionContents("픽픽픽 옵션2 내용"), + new Count(0), PickOptionType.secondPickOption); + pickOptionRepository.saveAll(List.of(firstPickOption, secondPickOption)); + + // 픽픽픽 옵션 이미지 생성 + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Slice picksMain = memberPickServiceV2.findPicksMain(pageable, null, null, null, null); + + // then + Pick findPick = pickRepository.findById(pick.getId()).get(); + assertThat(picksMain).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(picksMain.getContent().get(0).getPickOptions()).hasSize(2) + .extracting("id", "title", "percent", "isPicked", "content", "thumbnailImageUrl") + .containsExactly( + tuple(pickOptions.get(0).getId(), pickOptions.get(0).getTitle().getTitle(), 100, + false, "픽픽픽 옵션1 내용", "http://iamge1.png"), + tuple(pickOptions.get(1).getId(), pickOptions.get(1).getTitle().getTitle(), 0, + false, "픽픽픽 옵션2 내용", "http://iamge2.png") + ); + } + + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, + Count poplarScore, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .viewTotalCount(viewTotalCount) + .voteTotalCount(voteTotalCount) + .commentTotalCount(commentTotalCount) + .popularScore(poplarScore) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOption createPickOption(Title title, Count voteTotalCount, PickOptionType pickOptionType, Pick pick) { + PickOption pickOption = PickOption.builder() + .title(title) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private PickVote createPickVote(Member member, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .member(member) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private PickVote createPickVote(AnonymousMember anonymousMember, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .anonymousMember(anonymousMember) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private Pick createPick(Title title, Count pickVoteCount, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOptionImage createPickOptionImage(String name, String imageUrl, PickOption pickOption) { + PickOptionImage pickOptionImage = PickOptionImage.builder() + .name(name) + .imageUrl(imageUrl) + .imageKey("imageKey") + .build(); + + pickOptionImage.changePickOption(pickOption); + + return pickOptionImage; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, Count pickPopularScore, String thumbnailUrl, + String author, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .popularScore(pickPopularScore) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, String thumbnailUrl, String author, + List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Pick pick, Title title, PickOptionContents pickOptionContents, + Count voteTotalCount, PickOptionType pickOptionType) { + PickOption pickOption = PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java index a1ce577c..75cc24a9 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java @@ -20,6 +20,8 @@ import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; import java.util.List; + +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -88,6 +90,45 @@ void findTop3SimilarPicks() { ); } + @Test + @DisplayName("타겟 픽픽픽을 기준으로 다른 픽픽픽과 유사도를 계산하여 타겟 픽픽픽을 제외한 승인상태인 상위 3개의 픽픽픽을 조회한다.") + void findTop3SimilarPicksV2() { + // given + // 픽픽픽 작성자 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, + List.of(1.0, 1.0, 1.0)); + Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, + List.of(0.1, 0.2, 0.3)); + Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, + List.of(0.2, 0.3, 0.4)); + Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, + List.of(0.3, 0.4, 0.5)); + Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, + List.of(0.4, 0.5, 0.6)); + Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, + List.of(0.4, 0.5, 0.6)); + pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + + // when + List top3SimilarPicks = pickCommonService.findTop3SimilarPicksV2(targetPick.getId()); + + // then + assertThat(top3SimilarPicks).hasSize(3) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "similarity", "isNew") + .containsExactly( + tuple(pick3.getId(), pick3.getTitle().getTitle(), pick3.getVoteTotalCount().getCount(), + pick3.getCommentTotalCount().getCount(), 0.9797958971132711, true), + tuple(pick2.getId(), pick2.getTitle().getTitle(), pick2.getVoteTotalCount().getCount(), + pick2.getCommentTotalCount().getCount(), 0.9649012813540153, true), + tuple(pick1.getId(), pick1.getTitle().getTitle(), pick1.getVoteTotalCount().getCount(), + pick1.getCommentTotalCount().getCount(), 0.9258200997725515, true) + ); + } + @Test @DisplayName("타겟 픽픽픽을 기준으로 다른 픽픽픽과 유사도를 계산하여 타겟 픽픽픽을 제외한 상위 3개의 픽픽픽을 조회할 때 타겟 픽픽픽이 없으면 예외가 발생한다.") void findTop3SimilarPicks_INVALID_NOT_FOUND_PICK_MESSAGE() { diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java new file mode 100644 index 00000000..d27598a3 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java @@ -0,0 +1,54 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MarkdownUtils 테스트") +class MarkdownUtilsTest { + + @Test + @DisplayName("복합 마크다운을 텍스트로 변환한다") + void convertMarkdownToText() { + // given + String markdown = """ + # 제목 + + **굵은 글씨**와 *이탤릭* 그리고 [링크](https://example.com) + + - 항목1 + - 항목2 + + `코드` + """; + + // when + String result = MarkdownUtils.convertMarkdownToText(markdown); + + // then + assertThat(result).contains("제목"); + assertThat(result).contains("굵은 글씨와 이탤릭 그리고"); + assertThat(result).contains("링크"); + assertThat(result).contains("항목1"); + assertThat(result).contains("항목2"); + assertThat(result).contains("코드"); + assertThat(result).doesNotContain("#"); + assertThat(result).doesNotContain("**"); + assertThat(result).doesNotContain("["); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("null 또는 빈 문자열은 그대로 반환한다") + void convertMarkdownToText_nullOrEmpty(String input) { + // when + String result = MarkdownUtils.convertMarkdownToText(input); + + // then + assertThat(result).isEqualTo(input); + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java new file mode 100644 index 00000000..2144e778 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java @@ -0,0 +1,52 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TimeUtils 테스트") +class TimeUtilsTest { + + // 기준 시간: 2025-10-26 10:00:00 + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2025, 10, 26, 10, 0, 0); + + @ParameterizedTest + @MethodSource("provideWithinOneWeekDates") + @DisplayName("날짜 기준 7일 이내는 true를 반환한다") + void isWithinOneWeek_true(LocalDateTime testTime) { + // when // then + assertThat(TimeUtils.isWithinOneWeek(testTime, BASE_TIME)) + .isTrue(); + } + + @ParameterizedTest + @MethodSource("provideOverOneWeekDates") + @DisplayName("날짜 기준 7일 초과는 false를 반환한다") + void isWithinOneWeek_false(LocalDateTime testTime) { + // when // then + assertThat(TimeUtils.isWithinOneWeek(testTime, BASE_TIME)) + .isFalse(); + } + + private static Stream provideWithinOneWeekDates() { + return Stream.of( + LocalDateTime.of(2025, 10, 26, 10, 0, 0), // 오늘 (2025-10-26) + LocalDateTime.of(2025, 10, 25, 15, 30, 0), // 1일 전 (2025-10-25) + LocalDateTime.of(2025, 10, 20, 8, 0, 0), // 6일 전 (2025-10-20) + LocalDateTime.of(2025, 10, 19, 23, 59, 59) // 7일 전 (2025-10-19) + ); + } + + private static Stream provideOverOneWeekDates() { + return Stream.of( + LocalDateTime.of(2025, 10, 18, 10, 0, 0), // 8일 전 (2025-10-18) + LocalDateTime.of(2025, 10, 12, 10, 0, 0), // 14일 전 (2025-10-12) + LocalDateTime.of(2025, 9, 26, 10, 0, 0) // 30일 전 (2025-09-26) + ); + } +} diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java new file mode 100644 index 00000000..50e37eb8 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java @@ -0,0 +1,199 @@ +package com.dreamypatisiel.devdevdev.web.controller.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainOptionResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PickControllerV2Test extends SupportControllerTest { + + @MockBean + GuestPickServiceV2 guestPickServiceV2; + @MockBean + MemberPickServiceV2 memberPickServiceV2; + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void getPicksMainByMember() throws Exception { + // given + Pageable pageable = PageRequest.of(0, 10); + + // Mock 데이터 생성 + PickMainOptionResponseV2 option1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("픽옵션1")) + .percent(new java.math.BigDecimal("50")) + .isPicked(true) + .content("픽콘텐츠1") + .thumbnailImageUrl("http://image1.png") + .build(); + + PickMainOptionResponseV2 option2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("픽옵션2")) + .percent(new java.math.BigDecimal("50")) + .isPicked(false) + .content("픽콘텐츠2") + .thumbnailImageUrl("http://image2.png") + .build(); + + PickMainResponseV2 pickMainResponse = PickMainResponseV2.builder() + .id(1L) + .title(new Title("픽1타이틀")) + .voteTotalCount(new Count(2)) + .commentTotalCount(new Count(2)) + .viewTotalCount(new Count(2)) + .popularScore(new Count(100)) + .isVoted(true) + .isNew(true) + .pickOptions(List.of(option1, option2)) + .build(); + + Slice result = new SliceCustom<>(List.of(pickMainResponse), pageable, false, 1L); + when(memberPickServiceV2.findPicksMain(any(Pageable.class), any(), any(), + any(), any(Authentication.class))).thenReturn(result); + + // when // then + mockMvc.perform(get("/devdevdev/api/v2/picks") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("pickSort", PickSort.LATEST.name()) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].title").isString()) + .andExpect(jsonPath("$.data.content.[0].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].viewTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].popularScore").isNumber()) + .andExpect(jsonPath("$.data.content.[0].isVoted").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].pickOptions").isArray()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].title").isString()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].percent").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].isPicked").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].title").isString()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].percent").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].isPicked").isBoolean()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + } + + @Test + @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") + void getSimilarPicks() throws Exception { + // given + Long targetPickId = 1L; + + // Mock 데이터 생성 + SimilarPickResponseV2 similarPick1 = SimilarPickResponseV2.builder() + .id(2L) + .title("유쏘영") + .voteTotalCount(2L) + .commentTotalCount(5L) + .similarity(0.95) + .isNew(true) + .build(); + + SimilarPickResponseV2 similarPick2 = SimilarPickResponseV2.builder() + .id(3L) + .title("소영쏘") + .voteTotalCount(3L) + .commentTotalCount(4L) + .similarity(0.85) + .isNew(false) + .build(); + + SimilarPickResponseV2 similarPick3 = SimilarPickResponseV2.builder() + .id(4L) + .title("쏘영쏘") + .voteTotalCount(4L) + .commentTotalCount(3L) + .similarity(0.75) + .isNew(false) + .build(); + + List result = List.of(similarPick1, similarPick2, similarPick3); + when(memberPickServiceV2.findTop3SimilarPicksV2(anyLong())).thenReturn(result); + + // when // then + mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPickId) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(SUCCESS.name())) + .andExpect(jsonPath("$.datas").isArray()) + .andExpect(jsonPath("$.datas.[0].id").isNumber()) + .andExpect(jsonPath("$.datas.[0].title").isString()) + .andExpect(jsonPath("$.datas.[0].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[0].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[0].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[0].isNew").isBoolean()) + .andExpect(jsonPath("$.datas.[1].id").isNumber()) + .andExpect(jsonPath("$.datas.[1].title").isString()) + .andExpect(jsonPath("$.datas.[1].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[1].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[1].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[1].isNew").isBoolean()) + .andExpect(jsonPath("$.datas.[2].id").isNumber()) + .andExpect(jsonPath("$.datas.[2].title").isString()) + .andExpect(jsonPath("$.datas.[2].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[2].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[2].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[2].isNew").isBoolean()); + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java new file mode 100644 index 00000000..1f616eaf --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -0,0 +1,246 @@ +package com.dreamypatisiel.devdevdev.web.docs; + +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainOptionResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.authenticationType; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.pickSortType; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PickControllerV2DocsTest extends SupportControllerDocsTest { + + @MockBean + GuestPickServiceV2 guestPickServiceV2; + @MockBean + MemberPickServiceV2 memberPickServiceV2; + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void getPicksMainByMember() throws Exception { + // given + Pageable pageable = PageRequest.of(0, 10); + + // Mock 데이터 생성 + PickMainOptionResponseV2 option1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("픽옵션1")) + .percent(new java.math.BigDecimal("50")) + .isPicked(true) + .content("픽콘텐츠1") + .thumbnailImageUrl("http://image1.png") + .build(); + + PickMainOptionResponseV2 option2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("픽옵션2")) + .percent(new java.math.BigDecimal("50")) + .isPicked(false) + .content("픽콘텐츠2") + .thumbnailImageUrl("http://image2.png") + .build(); + + PickMainResponseV2 pickMainResponse = PickMainResponseV2.builder() + .id(1L) + .title(new Title("픽1타이틀")) + .voteTotalCount(new Count(2)) + .commentTotalCount(new Count(2)) + .viewTotalCount(new Count(2)) + .popularScore(new Count(100)) + .isVoted(true) + .isNew(true) + .pickOptions(List.of(option1, option2)) + .build(); + + Slice result = new SliceCustom<>(List.of(pickMainResponse), pageable, false, 1L); + when(memberPickServiceV2.findPicksMain(any(Pageable.class), any(), any(), + any(), any(Authentication.class))).thenReturn(result); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("pickSort", PickSort.LATEST.name()) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-main-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), + queryParameters( + parameterWithName("pickId").optional().description("픽픽픽 아이디"), + parameterWithName("pickSort").optional().description("픽픽픽 정렬 조건").attributes(pickSortType()), + parameterWithName("size").optional().description("조회되는 데이터 수") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + + fieldWithPath("data.content").type(ARRAY).description("픽픽픽 메인 배열"), + fieldWithPath("data.content[].id").type(NUMBER).description("픽픽픽 아이디"), + fieldWithPath("data.content[].title").type(STRING).description("픽픽픽 제목"), + fieldWithPath("data.content[].voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수"), + fieldWithPath("data.content[].commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수"), + fieldWithPath("data.content[].viewTotalCount").type(NUMBER).description("픽픽픽 조회 수"), + fieldWithPath("data.content[].popularScore").type(NUMBER).description("픽픽픽 인기점수"), + fieldWithPath("data.content[].isVoted").attributes(authenticationType()).type(BOOLEAN) + .description("픽픽픽 투표 여부(익명 사용자는 필드가 없다.)"), + fieldWithPath("data.content[].isNew").attributes(authenticationType()).type(BOOLEAN) + .description("일주일 이내 게시글 여부 (NEW)"), + + fieldWithPath("data.content[].pickOptions").type(ARRAY).description("픽픽픽 옵션 배열"), + fieldWithPath("data.content[].pickOptions[].id").type(NUMBER).description("픽픽픽 옵션 아이디"), + fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), + fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), + fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING).description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( + BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), + fieldWithPath("data.content[].pickOptions[].id").type(NUMBER).description("픽픽픽 옵션 아이디"), + fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), + fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), + fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING).description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( + BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), + + fieldWithPath("data.pageable").type(OBJECT).description("픽픽픽 메인 페이지네이션 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 사이즈"), + + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 여부"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("비정렬 여부"), + + fieldWithPath("data.pageable.offset").type(NUMBER).description("페이지 오프셋 (페이지 크기 * 페이지 번호)"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이지 정보 포함 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이지 정보 비포함 여부"), + + fieldWithPath("data.first").type(BOOLEAN).description("현재 페이지가 첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("현재 페이지가 마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지"), + + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 상태 여부"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("비정렬 상태 여부"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.totalElements").type(NUMBER).description("전체 픽픽픽 데이터 수 (NEW)"), + fieldWithPath("data.empty").type(BOOLEAN).description("현재 빈 페이지 여부") + ) + )); + } + + @Test + @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") + void getSimilarPicks() throws Exception { + // given + Long targetPickId = 1L; + + // Mock 데이터 생성 + SimilarPickResponseV2 similarPick1 = SimilarPickResponseV2.builder() + .id(2L) + .title("유쏘영") + .voteTotalCount(2L) + .commentTotalCount(5L) + .similarity(0.95) + .isNew(true) + .build(); + + SimilarPickResponseV2 similarPick2 = SimilarPickResponseV2.builder() + .id(3L) + .title("소영쏘") + .voteTotalCount(3L) + .commentTotalCount(4L) + .similarity(0.85) + .isNew(false) + .build(); + + SimilarPickResponseV2 similarPick3 = SimilarPickResponseV2.builder() + .id(4L) + .title("쏘영쏘") + .voteTotalCount(4L) + .commentTotalCount(3L) + .similarity(0.75) + .isNew(false) + .build(); + + List result = List.of(similarPick1, similarPick2, similarPick3); + when(memberPickServiceV2.findTop3SimilarPicksV2(anyLong())).thenReturn(result); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPickId) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-similarity-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("pickId").description("픽픽픽 아이디") + ), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("datas").type(ARRAY).description("나도 고민했는데 픽픽픽 배열"), + fieldWithPath("datas[].id").type(NUMBER).description("픽픽픽 아이디"), + fieldWithPath("datas[].title").type(STRING).description("픽픽픽 제목"), + fieldWithPath("datas[].voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수"), + fieldWithPath("datas[].commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수"), + fieldWithPath("datas[].similarity").type(NUMBER).description("픽픽픽 유사도"), + fieldWithPath("datas[].isNew").type(BOOLEAN).description("일주일 이내 게시글 여부 (NEW)") + ) + )); + } +} +