diff --git a/src/docs/asciidoc/user-api.adoc b/src/docs/asciidoc/user-api.adoc index aaf7f54..2ad3d7f 100644 --- a/src/docs/asciidoc/user-api.adoc +++ b/src/docs/asciidoc/user-api.adoc @@ -177,6 +177,7 @@ include::{snippetsDir}/userExit/1/http-response.adoc[] ==== Response Body Fields include::{snippetsDir}/userExit/1/response-fields.adoc[] + === **10. 북마크 생성 api** 게시글 북마크를 생성 @@ -199,4 +200,36 @@ include::{snippetsDir}/createBookmark/1/response-fields.adoc[] ==== 실패 Response 실패1. -include::{snippetsDir}/createBookmark/3/http-response.adoc[] \ No newline at end of file +include::{snippetsDir}/createBookmark/3/http-response.adoc[] + + +=== **11. 내가 작성한 유저픽 게시글 목록 조회** + +마이페이지 > 작성한 게시글 목록 > 더보기 버튼 클릭 시 내가 작성한 유저픽 게시글 목록을 조회하는 api 입니다. + +무한 스크롤, 더보기 형식으로 조회하여 모든 데이터 개수와 번호를 부여하는 Page 방식이 아닌, + +다음 페이지의 데이터가 존재하는지의 여부를 함께 응답하는 Slice 방식을 사용했습니다. + +==== Request +include::{snippetsDir}/loadMyPosts/1/http-request.adoc[] + +==== Request Query Parameter Fields +include::{snippetsDir}/loadMyPosts/1/query-parameters.adoc[] + +==== 성공 Response +include::{snippetsDir}/loadMyPosts/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/loadMyPosts/1/response-fields.adoc[] + +==== 실패 Response +실패 1. 인증되지 않은 유저인 경우 + +include::{snippetsDir}/loadMyPosts/2/http-response.adoc[] + +실패 2. 요청 페이지 번호 유효성 검증에 실패할 경우 + +include::{snippetsDir}/loadMyPosts/3/http-response.adoc[] + +실패 3. 요청 페이지당 개수 유효성 검증에 실패할 경우 + +include::{snippetsDir}/loadMyPosts/4/http-response.adoc[] \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/controller/LoadMyPostsController.java b/src/main/java/com/ftm/server/adapter/in/web/user/controller/LoadMyPostsController.java new file mode 100644 index 0000000..d59ddda --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/controller/LoadMyPostsController.java @@ -0,0 +1,43 @@ +package com.ftm.server.adapter.in.web.user.controller; + +import com.ftm.server.adapter.in.web.user.dto.response.LoadMyPostsResponse; +import com.ftm.server.application.port.in.user.LoadMyPostsUseCase; +import com.ftm.server.application.query.FindPostsByPagingQuery; +import com.ftm.server.application.vo.post.PostPagingVo; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LoadMyPostsController { + + private final LoadMyPostsUseCase loadMyPostsUseCase; + + @GetMapping("/api/users/me/posts") + public ResponseEntity> loadMyPosts( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "5") int size) { + // 요청 페이징 데이터 유효성 검증 + if (page < 0) throw new CustomException(ErrorResponseCode.BAD_REQUEST_PAGING_INDEX_RANGE); + if (size < 1 || size > 10) + throw new CustomException(ErrorResponseCode.BAD_REQUEST_PAGING_SIZE_RANGE); + + PostPagingVo vo = + loadMyPostsUseCase.execute( + FindPostsByPagingQuery.of(userPrincipal.getId(), page, size)); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessResponseCode.OK, LoadMyPostsResponse.from(vo))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/LoadMyPostsResponse.java b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/LoadMyPostsResponse.java new file mode 100644 index 0000000..e3f2fbb --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/LoadMyPostsResponse.java @@ -0,0 +1,22 @@ +package com.ftm.server.adapter.in.web.user.dto.response; + +import com.ftm.server.application.vo.post.PostPagingVo; +import com.ftm.server.application.vo.post.PostSummaryVo; +import java.util.List; +import lombok.Getter; + +@Getter +public class LoadMyPostsResponse { + + private final List items; + private final Boolean hasNext; + + private LoadMyPostsResponse(PostPagingVo postPagingVo) { + this.items = postPagingVo.getItems(); + this.hasNext = postPagingVo.getHasNext(); + } + + public static LoadMyPostsResponse from(PostPagingVo postPagingVo) { + return new LoadMyPostsResponse(postPagingVo); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java index d23ff91..06c301d 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Slice; @Adapter @RequiredArgsConstructor @@ -32,6 +33,7 @@ public class UserDomainPersistenceAdapter UpdateUserPort, UpdateUserImagePort, LoadPostUserDomainPort, + LoadPostImageUserDomainPort, UpdatePostUserDomainPort, DeleteUserImagePort, DeleteGroomingTestResultPort, @@ -47,6 +49,7 @@ public class UserDomainPersistenceAdapter private final GroomingLevelRepository groomingLevelRepository; private final UserImageRepository userImageRepository; private final PostRepository postRepository; + private final PostImageRepository postImageRepository; private final BookmarkRepository bookmarkRepository; private final GroomingTestResultRepository groomingTestResultRepository; @@ -56,6 +59,7 @@ public class UserDomainPersistenceAdapter private final UserImageMapper userImageMapper; private final PostMapper postMapper; private final BookmarkMapper bookmarkMapper; + private final PostImageMapper postImageMapper; @Override public Optional loadEmailVerificationLogByEmail(FindByEmailQuery query) { @@ -193,6 +197,18 @@ public List loadPostListByUser(FindByUserIdQuery query) { .toList(); } + @Override + public Slice loadPostsByUserIdWithPaging(FindPostsByPagingQuery query) { + return postRepository.findAllByUserIdWithPaging(query).map(postMapper::toDomainEntity); + } + + @Override + public List loadRepresentativeImagesByPostIds(FindByIdsQuery query) { + return postImageRepository.findRepresentativeImagesByPostIdIn(query).stream() + .map(postImageMapper::toDomainEntity) + .toList(); + } + @Override public void updatePostListBySystemUser(List postList) { UserJpaEntity systemUser = userRepository.findById(postList.get(0).getUserId()).get(); diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java index 4d8e6e5..443173d 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java @@ -2,9 +2,13 @@ import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import com.ftm.server.application.query.FindPostsByPagingQuery; import java.util.List; +import org.springframework.data.domain.Slice; public interface PostCustomRepository { List findAllByDeletedBefore(FindPostByDeleteOptionQuery query); + + Slice findAllByUserIdWithPaging(FindPostsByPagingQuery query); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java index 693fcee..a47d30c 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java @@ -4,10 +4,14 @@ import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import com.ftm.server.application.query.FindPostsByPagingQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalTime; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; @Repository @@ -25,4 +29,26 @@ public List findAllByDeletedBefore(FindPostByDeleteOptionQuery qu postJpaEntity.deletedAt.loe(query.getDeletedAt().atTime(LocalTime.MAX))) .fetch(); } + + @Override + public Slice findAllByUserIdWithPaging(FindPostsByPagingQuery query) { + Pageable pageable = query.getPageable(); + List content = + queryFactory + .selectFrom(postJpaEntity) + .where(postJpaEntity.user.id.eq(query.getUserId())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) // 한 개 더 가져와서 hasNext 판별 + .orderBy(postJpaEntity.createdAt.desc()) + .fetch(); + + List result = content; + + boolean hasNext = content.size() > pageable.getPageSize(); + if (hasNext) { + result = content.subList(0, pageable.getPageSize()); // 초과분 제거 + } + + return new SliceImpl<>(result, pageable, hasNext); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepository.java new file mode 100644 index 0000000..71cf8f7 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepository.java @@ -0,0 +1,11 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import com.ftm.server.adapter.out.persistence.model.PostImageJpaEntity; +import com.ftm.server.application.query.FindByIdsQuery; +import java.util.List; + +public interface PostImageCustomRepository { + + // 여러개의 게시글 이미지 중 대표 이미지 한 개 조회 (썸네일용 이미지, 업로드가 가장 먼저된 이미지 조회) + List findRepresentativeImagesByPostIdIn(FindByIdsQuery query); +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepositoryImpl.java new file mode 100644 index 0000000..e46d8b3 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageCustomRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import static com.ftm.server.adapter.out.persistence.model.QPostImageJpaEntity.postImageJpaEntity; + +import com.ftm.server.adapter.out.persistence.model.PostImageJpaEntity; +import com.ftm.server.application.query.FindByIdsQuery; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PostImageCustomRepositoryImpl implements PostImageCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findRepresentativeImagesByPostIdIn(FindByIdsQuery query) { + return queryFactory + .selectFrom(postImageJpaEntity) + .where( + postImageJpaEntity.id.in( + JPAExpressions.select(postImageJpaEntity.id.min()) + .from(postImageJpaEntity) + .where(postImageJpaEntity.post.id.in(query.getIds())) + .groupBy(postImageJpaEntity.post.id))) + .fetch(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java index 392527b..95eca3d 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java @@ -8,7 +8,8 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -public interface PostImageRepository extends JpaRepository { +public interface PostImageRepository + extends JpaRepository, PostImageCustomRepository { List findAllByPost(PostJpaEntity post); diff --git a/src/main/java/com/ftm/server/application/port/in/user/LoadMyPostsUseCase.java b/src/main/java/com/ftm/server/application/port/in/user/LoadMyPostsUseCase.java new file mode 100644 index 0000000..ec81191 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/user/LoadMyPostsUseCase.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.in.user; + +import com.ftm.server.application.query.FindPostsByPagingQuery; +import com.ftm.server.application.vo.post.PostPagingVo; +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface LoadMyPostsUseCase { + + PostPagingVo execute(FindPostsByPagingQuery query); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostImageUserDomainPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostImageUserDomainPort.java new file mode 100644 index 0000000..5d57123 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostImageUserDomainPort.java @@ -0,0 +1,12 @@ +package com.ftm.server.application.port.out.persistence.user; + +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostImage; +import java.util.List; + +@Port +public interface LoadPostImageUserDomainPort { + + List loadRepresentativeImagesByPostIds(FindByIdsQuery query); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostUserDomainPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostUserDomainPort.java index 0cfda54..d2ec4ca 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostUserDomainPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadPostUserDomainPort.java @@ -1,10 +1,14 @@ package com.ftm.server.application.port.out.persistence.user; import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.query.FindPostsByPagingQuery; import com.ftm.server.domain.entity.Post; import java.util.List; +import org.springframework.data.domain.Slice; public interface LoadPostUserDomainPort { List loadPostListByUser(FindByUserIdQuery query); + + Slice loadPostsByUserIdWithPaging(FindPostsByPagingQuery query); } diff --git a/src/main/java/com/ftm/server/application/query/FindPostsByPagingQuery.java b/src/main/java/com/ftm/server/application/query/FindPostsByPagingQuery.java new file mode 100644 index 0000000..862058a --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindPostsByPagingQuery.java @@ -0,0 +1,21 @@ +package com.ftm.server.application.query; + +import lombok.Getter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@Getter +public class FindPostsByPagingQuery { + + private final Long userId; + private final Pageable pageable; + + private FindPostsByPagingQuery(Long userId, int page, int size) { + this.userId = userId; + this.pageable = PageRequest.of(page, size); + } + + public static FindPostsByPagingQuery of(Long userId, int page, int size) { + return new FindPostsByPagingQuery(userId, page, size); + } +} diff --git a/src/main/java/com/ftm/server/application/service/user/LoadMyPostsService.java b/src/main/java/com/ftm/server/application/service/user/LoadMyPostsService.java new file mode 100644 index 0000000..0887ea6 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/user/LoadMyPostsService.java @@ -0,0 +1,58 @@ +package com.ftm.server.application.service.user; + +import com.ftm.server.application.port.in.user.LoadMyPostsUseCase; +import com.ftm.server.application.port.out.persistence.user.LoadPostImageUserDomainPort; +import com.ftm.server.application.port.out.persistence.user.LoadPostUserDomainPort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.query.FindPostsByPagingQuery; +import com.ftm.server.application.vo.post.PostPagingVo; +import com.ftm.server.application.vo.post.PostSummaryVo; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostImage; +import java.util.*; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoadMyPostsService implements LoadMyPostsUseCase { + + private final LoadPostUserDomainPort loadPostUserDomainPort; + private final LoadPostImageUserDomainPort loadPostImageUserDomainPort; + + @Override + public PostPagingVo execute(FindPostsByPagingQuery query) { + // 페이징된 게시글 목록 조회 (최신순 정렬) + Slice posts = loadPostUserDomainPort.loadPostsByUserIdWithPaging(query); + List sortedPosts = posts.getContent(); + + List postIds = sortedPosts.stream().map(Post::getId).toList(); + + // 각 게시글의 이미지 중 대표(썸네일) 이미지 한개씩만 조회 + List postImages = + loadPostImageUserDomainPort.loadRepresentativeImagesByPostIds( + FindByIdsQuery.from(postIds)); + + // postId -> 대표 이미지(PostImage) 매핑 (createdAt 기준) + Map postIdToImageMap = + postImages.stream() + .collect( + Collectors.toMap( + PostImage::getPostId, + Function.identity(), + BinaryOperator.minBy( + Comparator.comparing(PostImage::getCreatedAt)))); + + // 정렬된 순서를 보장하면서 게시글, 게시글 이미지 매핑 + List items = + sortedPosts.stream() + .map(post -> PostSummaryVo.from(post, postIdToImageMap.get(post.getId()))) + .toList(); + + return PostPagingVo.from(items, posts.hasNext()); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostPagingVo.java b/src/main/java/com/ftm/server/application/vo/post/PostPagingVo.java new file mode 100644 index 0000000..d1897e6 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/PostPagingVo.java @@ -0,0 +1,20 @@ +package com.ftm.server.application.vo.post; + +import java.util.List; +import lombok.Getter; + +@Getter +public class PostPagingVo { + + private final List items; + private final Boolean hasNext; + + private PostPagingVo(List items, Boolean hasNext) { + this.items = items; + this.hasNext = hasNext; + } + + public static PostPagingVo from(List items, Boolean hasNext) { + return new PostPagingVo(items, hasNext); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostSummaryVo.java b/src/main/java/com/ftm/server/application/vo/post/PostSummaryVo.java new file mode 100644 index 0000000..049b51b --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/PostSummaryVo.java @@ -0,0 +1,29 @@ +package com.ftm.server.application.vo.post; + +import static com.ftm.server.common.consts.PropertiesHolder.CDN_PATH; + +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostImage; +import lombok.Getter; + +@Getter +public class PostSummaryVo { + + private final Long id; + private final String title; + private final String createdAt; + private final String updatedAt; + private final String imageUrl; + + private PostSummaryVo(Post post, PostImage postImage) { + this.id = post.getId(); + this.title = post.getTitle(); + this.createdAt = post.getCreatedAt().toLocalDate().toString(); + this.updatedAt = post.getUpdatedAt().toLocalDate().toString(); + this.imageUrl = CDN_PATH + "/" + postImage.getObjectKey(); + } + + public static PostSummaryVo from(Post post, PostImage postImage) { + return new PostSummaryVo(post, postImage); + } +} diff --git a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java index b4e77a7..7d6ff8d 100644 --- a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java +++ b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java @@ -27,6 +27,10 @@ public enum ErrorResponseCode { POST_PRODUCT_IMAGE_ALREADY_EXISTS( HttpStatus.BAD_REQUEST, "E400_010", "이미 이미지가 존재합니다. 기존 이미지를 삭제하고 업로드 해주세요."), + BAD_REQUEST_PAGING_INDEX_RANGE(HttpStatus.BAD_REQUEST, "E400_011", "페이지 번호는 최소 0 이상이여야 합니다."), + BAD_REQUEST_PAGING_SIZE_RANGE( + HttpStatus.BAD_REQUEST, "E400_012", "페이지당 개수는 최소 1 이상, 10 이하여야 합니다."), + // 401번 NOT_AUTHENTICATED(HttpStatus.UNAUTHORIZED, "E401_001", "인증되지 않은 사용자입니다."), INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "E401_002", "인증에 실패하였습니다."), diff --git a/src/test/java/com/ftm/server/user/LoadMyPostsTest.java b/src/test/java/com/ftm/server/user/LoadMyPostsTest.java new file mode 100644 index 0000000..12f7711 --- /dev/null +++ b/src/test/java/com/ftm/server/user/LoadMyPostsTest.java @@ -0,0 +1,208 @@ +package com.ftm.server.user; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.in.post.SavePostUseCase; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import org.springframework.restdocs.snippet.Attributes; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class LoadMyPostsTest extends BaseTest { + + @MockitoSpyBean private S3PostImageUploadPort s3PostImageUploadPort; + @MockitoSpyBean private S3PostProductImageUploadPort s3PostProductImageUploadPort; + @MockitoSpyBean private S3ImageDeletePort s3ImageDeletePort; + @MockitoSpyBean private AfterRollbackExecutorPort afterRollbackExecutorPort; + @Autowired private SavePostUseCase savePostUseCase; + + private final ParameterDescriptor queryParametersForPage = + parameterWithName("page") + .optional() + .description("페이지 번호") + .attributes( + new Attributes.Attribute("constraint", "숫자, 최소 0 이상"), + new Attributes.Attribute("default", "0")); + + private final ParameterDescriptor queryParametersForSize = + parameterWithName("size") + .optional() + .description("페이지당 개수") + .attributes( + new Attributes.Attribute("constraint", "숫자, 최소 1 이상 최대 10 이하"), + new Attributes.Attribute("default", "5")); + + private final List responseFieldLoadMyPosts = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("응답 데이터"), + fieldWithPath("data.items[]").type(ARRAY).optional().description("작성한 게시글 목록"), + fieldWithPath("data.items[].id").type(NUMBER).description("게시글 ID"), + fieldWithPath("data.items[].title").type(STRING).description("게시글 제목"), + fieldWithPath("data.items[].createdAt").type(STRING).description("게시글 생성 날짜"), + fieldWithPath("data.items[].updatedAt").type(STRING).description("게시글 수정 날짜"), + fieldWithPath("data.items[].imageUrl") + .type(STRING) + .description("게시글 대표 이미지 URL"), + fieldWithPath("data.hasNext").type(BOOLEAN).description("다음 페이지 데이터 존재여부")); + + private ResultActions getResultActions(MockHttpSession session, int page, int size) + throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/users/me/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .session(session)); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "loadMyPosts/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFieldLoadMyPosts), + queryParameters(queryParametersForPage, queryParametersForSize), + resource( + ResourceSnippetParameters.builder() + .tag("회원") + .summary("작성한 유저픽 게시글 목록 조회 api") + .description("작성한 유저픽 게시글 목록을 조회하는 api 입니다.") + .responseFields(responseFieldLoadMyPosts) + .queryParameters(queryParametersForPage, queryParametersForSize) + .build())); + } + + @BeforeEach + void set_up() throws Exception { + User user = createTestUser("test@gmail.com", "test1234!"); + + // s3 실제 호출 대신 mock 대입 + doReturn(List.of()).when(s3PostImageUploadPort).uploadImages(new ArrayList<>(List.of())); + doReturn(List.of()) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(List.of())); + doNothing().when(s3ImageDeletePort).deleteImages(any()); + doNothing().when(afterRollbackExecutorPort).doAfterRollback(any()); + + for (int i = 0; i < 5; i++) { + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천 " + i, + GroomingCategory.BEAUTY, + List.of(HashTag.PERFUME), + "
test
", + List.of(new SavePostProductRequest(-1, "독도 토너", "라운드랩", List.of()))); + savePostUseCase.execute( + SavePostCommand.from(user.getId(), postRequest, List.of(), List.of())); + } + } + + @Test + @Transactional + void 작성한_유저픽_게시글_목록_조회_성공() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(session, 0, 4); + + // then + resultActions.andExpect(status().isOk()).andDo(print()); + + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 작성한_유저픽_게시글_목록_조회_실패1() throws Exception { + // when + ResultActions resultActions = + mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/users/me/posts") + .param("page", String.valueOf(0)) + .param("size", String.valueOf(5))); + + // then + resultActions.andExpect( + status().is(ErrorResponseCode.NOT_AUTHENTICATED.getHttpStatus().value())); + + resultActions.andDo(getDocument(2)); + } + + @Test + @Transactional + void 작성한_유저픽_게시글_목록_조회_실패2() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(session, -1, 5); + + // then + resultActions.andExpect( + status().is( + ErrorResponseCode.BAD_REQUEST_PAGING_INDEX_RANGE + .getHttpStatus() + .value())); + + resultActions.andDo(getDocument(3)); + } + + @Test + @Transactional + void 작성한_유저픽_게시글_목록_조회_실패3() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(session, 0, 100); + + // then + resultActions.andExpect( + status().is( + ErrorResponseCode.BAD_REQUEST_PAGING_SIZE_RANGE + .getHttpStatus() + .value())); + + resultActions.andDo(getDocument(4)); + } +}