diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java index 00cc4c052..586d113c6 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentQueryController.java @@ -1,10 +1,41 @@ package konkuk.thip.comment.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; +import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Comment Query API", description = "댓글 조회 관련 API") @RestController @RequiredArgsConstructor public class CommentQueryController { + public final CommentShowAllUseCase commentShowAllUseCase; + + @Operation( + summary = "댓글 전체 조회", + description = "특정 게시글(= 피드, 기록, 투표) 의 댓글과 대댓글들을 전체 조회합니다." + ) + @GetMapping("/comments/{postId}") + public BaseResponse showAllCommentsOfPost( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "댓글을 조회할 게시글(= FEED, RECORD, VOTE)의 id값") + @PathVariable("postId") final Long postId, + @Parameter(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") + @RequestParam(value = "postType") final String postType, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(value = "cursor", required = false) final String cursor) { + return BaseResponse.ok(commentShowAllUseCase.showAllCommentsOfPost( + CommentShowAllQuery.of(postId, userId, postType, cursor) + )); + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java b/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java new file mode 100644 index 000000000..56ad5678a --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/response/CommentForSinglePostResponse.java @@ -0,0 +1,58 @@ +package konkuk.thip.comment.adapter.in.web.response; + +import java.util.List; + +public record CommentForSinglePostResponse( + List commentList, + String nextCursor, + boolean isLast +) { + public record RootCommentDto( + Long commentId, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String alias, + String aliasColor, + String postDate, // 댓글 작성 시각 (~ 전 형식) + String content, + int likeCount, + boolean isLike, + boolean isDeleted, // 삭제된 댓글인지 아닌지 + List replyList + ) { + public record ReplyDto( + Long commentId, + String parentCommentCreatorNickname, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String alias, + String aliasColor, + String postDate, // 댓글 작성 시각 (~ 전 형식) + String content, + int likeCount, + boolean isLike + ) {} + + /** + * 삭제된 루트 댓글에 매핑되는 response dto + * isDelete 제외 나머지 데이터는 모두 쓰레기 값으로 + */ + public static RootCommentDto createDeletedRootCommentDto(List replyList) { + return new RootCommentDto( + null, + null, + null, + null, + null, + null, + null, + null, + 0, + false, + true, // true + replyList); + } + } +} diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeQueryPersistenceAdapter.java index ce173d259..fb4ed6b36 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentLikeQueryPersistenceAdapter.java @@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Set; + @Repository @RequiredArgsConstructor public class CommentLikeQueryPersistenceAdapter implements CommentLikeQueryPort { @@ -21,4 +23,9 @@ public class CommentLikeQueryPersistenceAdapter implements CommentLikeQueryPort public boolean isLikedCommentByUser(Long userId, Long commentId) { return commentLikeJpaRepository.existsByUserIdAndCommentId(userId, commentId); } + + @Override + public Set findCommentIdsLikedByUser(Set commentIds, Long userId) { + return commentLikeJpaRepository.findCommentIdsLikedByUser(commentIds, userId); + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java index a40d2e714..a4549ad20 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java @@ -3,14 +3,44 @@ import konkuk.thip.comment.adapter.out.mapper.CommentMapper; import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.comment.application.port.out.CommentQueryPort; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + @Repository @RequiredArgsConstructor public class CommentQueryPersistenceAdapter implements CommentQueryPort { - private final CommentJpaRepository jpaRepository; - private final CommentMapper userMapper; + private final CommentJpaRepository commentJpaRepository; + private final CommentMapper commentMapper; + + @Override + public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor) { + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int size = cursor.getPageSize(); + + List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, postTypeStr, lastCreatedAt, size); + + return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { + Cursor nextCursor = new Cursor(List.of(commentQueryDto.createdAt().toString())); + return nextCursor.toEncodedString(); + }); + } + + @Override + public List findAllActiveChildCommentsOldestFirst(Long rootCommentId) { + return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentId); + } + @Override + public Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds) { + return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentIds); + } } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java index 4e08628f9..c3761e06a 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentJpaRepository.java @@ -6,6 +6,6 @@ import java.util.Optional; -public interface CommentJpaRepository extends JpaRepository { +public interface CommentJpaRepository extends JpaRepository, CommentQueryRepository { Optional findByCommentIdAndStatus(Long commentId, StatusType status); } diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java index a79283150..939e7f279 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentLikeJpaRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Set; public interface CommentLikeJpaRepository extends JpaRepository { @@ -27,4 +28,6 @@ public interface CommentLikeJpaRepository extends JpaRepository findCommentIdsLikedByUser(@Param("commentIds") Set commentIds, @Param("userId") Long userId); +} diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java new file mode 100644 index 000000000..fe8b9b492 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java @@ -0,0 +1,17 @@ +package konkuk.thip.comment.adapter.out.persistence.repository; + +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface CommentQueryRepository { + + List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size); + + List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId); + + Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds); +} diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java new file mode 100644 index 000000000..b49144e73 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java @@ -0,0 +1,189 @@ +package konkuk.thip.comment.adapter.out.persistence.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.comment.adapter.out.jpa.QCommentJpaEntity; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.comment.application.port.out.dto.QCommentQueryDto; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class CommentQueryRepositoryImpl implements CommentQueryRepository { + + private final JPAQueryFactory queryFactory; + + private final QCommentJpaEntity comment = QCommentJpaEntity.commentJpaEntity; + private final QUserJpaEntity commentCreator = QUserJpaEntity.userJpaEntity; + private final QAliasJpaEntity aliasOfCommentCreator = QAliasJpaEntity.aliasJpaEntity; + private final QCommentJpaEntity parentComment = new QCommentJpaEntity("parentComment"); + private final QUserJpaEntity parentCommentCreator = new QUserJpaEntity("parentCommentCreator"); + + @Override + public List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size) { + // 최상위 댓글(size+1) 프로젝션 생성 + QCommentQueryDto proj = new QCommentQueryDto( + comment.commentId, + commentCreator.userId, + aliasOfCommentCreator.imageUrl, + commentCreator.nickname, + aliasOfCommentCreator.value, + aliasOfCommentCreator.color, + comment.createdAt, + comment.content, + comment.likeCount, + comment.status.eq(StatusType.INACTIVE) // 루트 댓글이 삭제된 상태인지 아닌지 + ); + + // WHERE 절 분리 + BooleanExpression whereClause = comment.postJpaEntity.postId.eq(postId) + .and(comment.postJpaEntity.dtype.eq(postTypeStr)) // dType 필터링 추가 + .and(comment.parent.isNull()) // 게시글의 최상위 댓글 조회 + .and(lastCreatedAt != null // 최신순 정렬 + ? comment.createdAt.lt(lastCreatedAt) + : Expressions.TRUE + ); + + // 조회 및 반환 + return queryFactory + .select(proj) + .from(comment) + .leftJoin(comment.userJpaEntity, commentCreator) + .leftJoin(commentCreator.aliasForUserJpaEntity, aliasOfCommentCreator) + .where(whereClause) + .orderBy(comment.createdAt.desc()) + .limit(size + 1) // size + 1 개 조회 + .fetch(); + } + + @Override + public List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId) { + List allDescendants = new ArrayList<>(); // 결과 누적용 리스트 + + // 1) 부모 ID 집합에 루트 댓글 ID 추가 + Set parentIds = new HashSet<>(); + parentIds.add(rootCommentId); + + // 2) 자손 댓글용 프로젝션 (부모 댓글 ID·작성자 닉네임 포함) + QCommentQueryDto childProj = new QCommentQueryDto( + comment.commentId, + comment.parent.commentId, + parentCommentCreator.nickname, + commentCreator.userId, + aliasOfCommentCreator.imageUrl, + commentCreator.nickname, + aliasOfCommentCreator.value, + aliasOfCommentCreator.color, + comment.createdAt, + comment.content, + comment.likeCount, + comment.status.eq(StatusType.INACTIVE) + ); + + // 3) 단계별 자식 댓글 조회 + while (!parentIds.isEmpty()) { + List children = queryFactory + .select(childProj) + .from(comment) + .leftJoin(comment.parent, parentComment) + .leftJoin(parentComment.userJpaEntity, parentCommentCreator) + .leftJoin(comment.userJpaEntity, commentCreator) + .leftJoin(commentCreator.aliasForUserJpaEntity, aliasOfCommentCreator) + .where( + comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 + comment.status.eq(StatusType.ACTIVE) // 자식 댓글은 ACTIVE인 것만 조회 + ) + .fetch(); + + if (children.isEmpty()) break; + + // 4) 누적 및 다음 단계 부모 ID 집합 갱신 + allDescendants.addAll(children); + parentIds = children.stream() + .map(CommentQueryDto::commentId) + .collect(Collectors.toSet()); + } + + // 5) 전체 자손 댓글을 깊이와 상관없이 작성 순으로 재정렬 + allDescendants.sort(Comparator.comparing(CommentQueryDto::createdAt)); + return allDescendants; + } + + @Override + public Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds) { + // 1) 루트 ID별로 최상위 매핑 초기화 + Map idToRoot = new HashMap<>(); + for (Long rootId : rootCommentIds) { + idToRoot.put(rootId, rootId); // 초기화 + } + + // 2) 결과 맵 초기화 + Map> resultMap = new HashMap<>(); + for (Long rootId : rootCommentIds) { + resultMap.put(rootId, new ArrayList<>()); + } + + // 3) 단계별 조회용 parentIds 초기화 + Set parentIds = new HashSet<>(rootCommentIds); + + // 4) 자손 댓글용 프로젝션 정의 + QCommentQueryDto childProj = new QCommentQueryDto( + comment.commentId, + comment.parent.commentId, + parentCommentCreator.nickname, + commentCreator.userId, + aliasOfCommentCreator.imageUrl, + commentCreator.nickname, + aliasOfCommentCreator.value, + aliasOfCommentCreator.color, + comment.createdAt, + comment.content, + comment.likeCount, + comment.status.eq(StatusType.INACTIVE) + ); + + // 5) 루프를 돌며 모든 깊이의 자식 댓글 조회 및 매핑 + while (!parentIds.isEmpty()) { + List children = queryFactory + .select(childProj) + .from(comment) + .leftJoin(comment.parent, parentComment) + .leftJoin(parentComment.userJpaEntity, parentCommentCreator) + .leftJoin(comment.userJpaEntity, commentCreator) + .leftJoin(commentCreator.aliasForUserJpaEntity, aliasOfCommentCreator) + .where( + comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 + comment.status.eq(StatusType.ACTIVE) // 자식 댓글은 ACTIVE인 것만 조회 + ) + .fetch(); + + if (children.isEmpty()) break; + + Set nextParentIds = new HashSet<>(); + for (CommentQueryDto child : children) { // 조회한 자식 댓글들에 대하여 + Long rootId = idToRoot.get(child.parentCommentId()); // 현재 자식댓글의 루트 댓글(부모 아님, 루트임) + + resultMap.get(rootId).add(child); // 해당 루트 ID의 리스트에 자식 댓글 추가 + + // 현재 자식 댓글도 다음 단계의 parentIds로 사용하기 위해 매핑 저장 + idToRoot.put(child.commentId(), rootId); + nextParentIds.add(child.commentId()); + } + parentIds = nextParentIds; // 한단계 아래 계층에서 활용할 부모 댓글들 + } + + // 6) 각 루트별 value 리스트를 작성시간순으로 정렬 + resultMap.values().forEach(list -> list.sort(Comparator.comparing(CommentQueryDto::createdAt))); + + return resultMap; + } +} diff --git a/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java new file mode 100644 index 000000000..b7661feb6 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/mapper/CommentQueryMapper.java @@ -0,0 +1,59 @@ +package konkuk.thip.comment.application.mapper; + +import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.util.DateUtil; +import org.mapstruct.*; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +@Mapper( + componentModel = "spring", + imports = DateUtil.class, + unmappedTargetPolicy = ReportingPolicy.IGNORE // 명시적으로 매핑하지 않은 필드를 무시하도록 설정 +) +public interface CommentQueryMapper { + + /** + * 정상(root) 댓글 매핑 (답글 제외) + */ + @Mapping(target = "replyList", expression = "java(new java.util.ArrayList<>())") + @Mapping(target = "isLike", expression = "java(likedCommentIds.contains(root.commentId()))") + @Mapping(target = "isDeleted", constant = "false") + @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(root.createdAt()))") + CommentForSinglePostResponse.RootCommentDto toRoot(CommentQueryDto root, + @Context Set likedCommentIds); + + /** + * 개별 답글 매핑 + */ + @Mapping(target = "isLike", expression = "java(likedCommentIds.contains(child.commentId()))") + @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(child.createdAt()))") + CommentForSinglePostResponse.RootCommentDto.ReplyDto toReply(CommentQueryDto child, @Context Set likedCommentIds); + + /** + * 답글 리스트 헬퍼 + */ + default List mapReplies(List children, @Context Set likedCommentIds) { + if (children == null || children.isEmpty()) { + return Collections.emptyList(); + } + return children.stream() + .map(child -> toReply(child, likedCommentIds)) + .toList(); + } + + default CommentForSinglePostResponse.RootCommentDto toRootCommentResponseWithChildren(CommentQueryDto root, List children, @Context Set likedCommentIds) { + List replyDtos = mapReplies(children, likedCommentIds); + + if (root.isDeleted()) { // 삭제된 루트 & children 이 존재하는 경우 + return CommentForSinglePostResponse.RootCommentDto.createDeletedRootCommentDto(replyDtos); + } + + CommentForSinglePostResponse.RootCommentDto rootDto = toRoot(root, likedCommentIds); + rootDto.replyList().addAll(replyDtos); + return rootDto; + } +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java b/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java new file mode 100644 index 000000000..f9d3bd21e --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/CommentShowAllUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.comment.application.port.in; + +import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; + +public interface CommentShowAllUseCase { + + CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery query); +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java new file mode 100644 index 000000000..3e860ef0f --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/in/dto/CommentShowAllQuery.java @@ -0,0 +1,15 @@ +package konkuk.thip.comment.application.port.in.dto; + + +import konkuk.thip.common.post.PostType; + +public record CommentShowAllQuery( + Long postId, + Long userId, + PostType postType, + String cursorStr +) { + public static CommentShowAllQuery of(Long postId, Long userId, String postTypeStr, String cursorStr) { + return new CommentShowAllQuery(postId, userId, PostType.from(postTypeStr), cursorStr); // 내부에서 PostType string 값 유효성 검증 + } +} diff --git a/src/main/java/konkuk/thip/comment/application/port/in/dto/DummyQuery.java b/src/main/java/konkuk/thip/comment/application/port/in/dto/DummyQuery.java deleted file mode 100644 index 4a80da494..000000000 --- a/src/main/java/konkuk/thip/comment/application/port/in/dto/DummyQuery.java +++ /dev/null @@ -1,9 +0,0 @@ -package konkuk.thip.comment.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyQuery { -} diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeQueryPort.java index 9de640d96..7281c109d 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentLikeQueryPort.java @@ -1,5 +1,9 @@ package konkuk.thip.comment.application.port.out; +import java.util.Set; + public interface CommentLikeQueryPort { boolean isLikedCommentByUser(Long userId, Long commentId); + + Set findCommentIdsLikedByUser(Set commentIds, Long userId); } diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java index cce75f165..770e92a32 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java @@ -1,5 +1,18 @@ package konkuk.thip.comment.application.port.out; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; + +import java.util.List; +import java.util.Map; +import java.util.Set; + public interface CommentQueryPort { + CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor); + + List findAllActiveChildCommentsOldestFirst(Long rootCommentId); + + Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds); } diff --git a/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java b/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java new file mode 100644 index 000000000..05475f02a --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/port/out/dto/CommentQueryDto.java @@ -0,0 +1,47 @@ +package konkuk.thip.comment.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import jakarta.annotation.Nullable; + +import java.time.LocalDateTime; + +public record CommentQueryDto( + Long commentId, + @Nullable Long parentCommentId, + @Nullable String parentCommentCreatorNickname, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String alias, + String aliasColor, + LocalDateTime createdAt, // 댓글 작성 시각 + String content, + int likeCount, + Boolean isDeleted +) { + /** + * child comment + */ + @QueryProjection + public CommentQueryDto {} + + /** + * root comment + */ + @QueryProjection + public CommentQueryDto ( + Long commentId, + Long creatorId, + String creatorProfileImageUrl, + String creatorNickname, + String alias, + String aliasColor, + LocalDateTime createdAt, // 댓글 작성 시각 + String content, + int likeCount, + boolean isDeleted + ) { + this(commentId, null, null, creatorId, creatorProfileImageUrl, creatorNickname, + alias, aliasColor, createdAt, content, likeCount, isDeleted); + } +} diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java new file mode 100644 index 000000000..7c946c167 --- /dev/null +++ b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java @@ -0,0 +1,81 @@ +package konkuk.thip.comment.application.service; + +import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; +import konkuk.thip.comment.application.mapper.CommentQueryMapper; +import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; +import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; +import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; +import konkuk.thip.comment.application.port.out.CommentQueryPort; +import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentShowAllService implements CommentShowAllUseCase { + + private static final int PAGE_SIZE = 10; + private final CommentQueryPort commentQueryPort; + private final CommentLikeQueryPort commentLikeQueryPort; + private final CommentQueryMapper commentQueryMapper; + + @Override + public CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery query) { + Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); + + // 1. size 크기만큼의 루트 댓글 최신순 조회 -> 삭제된 루트 댓글 포함해서 전부 조회 + CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), query.postType().getType(), cursor); + List rootsInOrder = commentQueryDtoCursorBasedList.contents(); + + // 2. 조회한 루트 댓글들의 전체 자식 댓귿들을(깊이 무관) 작성 시간순으로 조회 + Set rootCommentIds = rootsInOrder.stream() + .map(CommentQueryDto::commentId) + .collect(Collectors.toUnmodifiableSet()); + + Map> childrenMap = commentQueryPort.findAllActiveChildCommentsOldestFirst(rootCommentIds); + + // 3. 반환할 모든 댓글(루트 + 자식 모두 포함) 중 유저가 좋아한 댓글 조회 + Set allCommentIds = parseAllCommentIds(childrenMap); + Set likedCommentIds = commentLikeQueryPort.findCommentIdsLikedByUser(allCommentIds, query.userId()); + + // 4. response 매핑 + List rootCommentResponses = buildRootCommentResponses(rootsInOrder, childrenMap, likedCommentIds); + + return new CommentForSinglePostResponse( + rootCommentResponses, + commentQueryDtoCursorBasedList.nextCursor(), + commentQueryDtoCursorBasedList.isLast() + ); + } + + private Set parseAllCommentIds(Map> childrenMap) { + Set allCommentIds = new HashSet<>(childrenMap.keySet()); // 루트 댓글들 + for (Long rootCommentId : childrenMap.keySet()) { + childrenMap.get(rootCommentId).stream() + .map(CommentQueryDto::commentId) + .forEach(allCommentIds::add); + } + return allCommentIds; + } + + private List buildRootCommentResponses( + List roots, + Map> childrenMap, + Set likedCommentIds) { + List responses = new ArrayList<>(); + for (CommentQueryDto root : roots) { + List children = childrenMap.getOrDefault(root.commentId(), Collections.emptyList()); + // 삭제된 루트 댓글이면서 자식이 없는 경우 건너뛰기 + if (root.isDeleted() && children.isEmpty()) { + continue; + } + responses.add(commentQueryMapper.toRootCommentResponseWithChildren(root, children, likedCommentIds)); + } + return responses; + } +} diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java new file mode 100644 index 000000000..e254d4187 --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllApiTest.java @@ -0,0 +1,367 @@ +package konkuk.thip.comment.adapter.in.web; + +import com.jayway.jsonpath.JsonPath; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; +import konkuk.thip.common.post.PostType; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 댓글 조회 api 통합 테스트") +class CommentShowAllApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private CommentLikeJpaRepository commentLikeJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private static final String FEED_POST_TYPE = PostType.FEED.getType(); + + @AfterEach + void tearDown() { + commentLikeJpaRepository.deleteAllInBatch(); + commentJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + aliasJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("댓글 조회 요청에 대하여, 특정 게시글(= 피드, 기록, 투표)의 루트 댓글, 루트 댓글의 모든 자식 댓글의 데이터를 구분하여 반환한다.") + void comment_show_all_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book + + // 피드, 댓글, 자식 댓글 생성 및 생성일 직접 설정 + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "댓글1_답글1", 8)); + + commentLikeJpaRepository.save(TestEntityFactory.createCommentLike(comment1, me)); // me가 comment1을 좋아함 + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), comment1_1.getCommentId()); + + //when //then + mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("postType", FEED_POST_TYPE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(1))) + /** + * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 + * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + */ + .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[0].creatorNickname", is(user1.getNickname()))) + .andExpect(jsonPath("$.data.commentList[0].content", is(comment1.getContent()))) + .andExpect(jsonPath("$.data.commentList[0].likeCount", is(comment1.getLikeCount()))) + .andExpect(jsonPath("$.data.commentList[0].isLike", is(true))) // me가 comment1을 좋아함 + .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(1))) // 자식 댓글 1개 존재 + + .andExpect(jsonPath("$.data.commentList[0].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 의 작성자 = user1 + .andExpect(jsonPath("$.data.commentList[0].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].creatorNickname", is(me.getNickname()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].content", is(comment1_1.getContent()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].likeCount", is(comment1_1.getLikeCount()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].isLike", is(false))); // me가 comment1_1을 좋아하지 않음 + } + + @Test + @DisplayName("루트 댓글은 최신순, 루트 댓글의 모든 자식 댓글은 작성 시각순으로 정렬하여 반환한다.") + void comment_show_all_ordering_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + UserJpaEntity user2 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user2")); + UserJpaEntity user3 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user3")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book + + // 피드, 댓글, 자식 댓글 생성 및 생성일 직접 설정 + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + CommentJpaEntity comment2 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user2, PostType.FEED, "댓글2", 10)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user3, PostType.FEED, comment1, "댓글1_답글1", 2)); + CommentJpaEntity comment1_2 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "댓글1_답글2", 8)); + CommentJpaEntity comment1_1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, user1, PostType.FEED, comment1_1, "댓글1_답글1_답글1", 3)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), comment2.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), comment1_1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(10)), comment1_2.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(5)), comment1_1_1.getCommentId()); + + + //when //then + mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("postType", FEED_POST_TYPE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(2))) + /** + * 정렬 조건 + * 게시글에 바로 달린 댓글들(= 루트 댓글) : 최신순 정렬 + * 루트 댓글의 모든 하위 댓글들 : 작성 시간 순 정렬 (최신순 역순) + */ + // 루트 댓글 정렬 확인 + .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment2.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))) + // 루트 댓글의 모든 자식 댓글 정렬 확인 + .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(0))) // comment2 는 자식 댓글 없음 + .andExpect(jsonPath("$.data.commentList[1].replyList", hasSize(3))) // comment1 은 총 3개의 자식 댓글 있음 + + .andExpect(jsonPath("$.data.commentList[1].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 작성자 = user1 + + .andExpect(jsonPath("$.data.commentList[1].replyList[1].commentId", is(comment1_2.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].replyList[1].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 작성자 = user1 + + .andExpect(jsonPath("$.data.commentList[1].replyList[2].commentId", is(comment1_1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].replyList[2].parentCommentCreatorNickname", is(user3.getNickname()))); // comment1_1_1의 부모 댓글(= comment1_1) 작성자 = user3 + } + + @Test + @DisplayName("삭제된 루트 댓글의 경우, 자식 댓글이 있으면 반환하고, 없으면 반환하지 않는다.") + void comment_show_all_deleted_root_comment_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book + + // 피드, 댓글, 자식 댓글 생성 및 생성일 직접 설정 + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + CommentJpaEntity comment1_1 = commentJpaRepository.save(TestEntityFactory.createReplyComment(f1, me, PostType.FEED, comment1, "댓글1_답글1", 8)); + CommentJpaEntity comment2 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글2", 5)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), comment1_1.getCommentId()); + + // comment1, 2 soft delete + jdbcTemplate.update( + "UPDATE comments SET status = 'INACTIVE' WHERE comment_id = ?", + comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET status = 'INACTIVE' WHERE comment_id = ?", + comment2.getCommentId()); + + //when //then + mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("postType", FEED_POST_TYPE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(1))) // comment1 만 조회된다 + /** + * 루트 댓글 : + * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + */ + .andExpect(jsonPath("$.data.commentList[0].commentId", nullValue())) + .andExpect(jsonPath("$.data.commentList[0].creatorNickname", nullValue())) + .andExpect(jsonPath("$.data.commentList[0].content", nullValue())) + .andExpect(jsonPath("$.data.commentList[0].isDeleted", is(true))) + .andExpect(jsonPath("$.data.commentList[0].replyList", hasSize(1))) // 자식 댓글 1개 존재 + + .andExpect(jsonPath("$.data.commentList[0].replyList[0].parentCommentCreatorNickname", is(user1.getNickname()))) // comment1_1의 부모 댓글(= comment1) 의 작성자 = user1 + .andExpect(jsonPath("$.data.commentList[0].replyList[0].commentId", is(comment1_1.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].creatorNickname", is(me.getNickname()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].content", is(comment1_1.getContent()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].likeCount", is(comment1_1.getLikeCount()))) + .andExpect(jsonPath("$.data.commentList[0].replyList[0].isLike", is(false))); // me가 comment1_1을 좋아하지 않음 + } + + @Test + @DisplayName("게시글에 달린 댓글이 많을 경우, 루트 댓글을 기준으로 페이징 처리 한다.") + void comment_show_all_page_test() throws Exception { + //given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(a0, "user1")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); // 공통 Book + + // 피드, 댓글, 자식 댓글 생성 및 생성일 직접 설정 + LocalDateTime base = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save(TestEntityFactory.createFeed(me, book, true, 10, 5, List.of())); + CommentJpaEntity comment1 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글1", 5)); + CommentJpaEntity comment2 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글2", 5)); + CommentJpaEntity comment3 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글3", 5)); + CommentJpaEntity comment4 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글4", 5)); + CommentJpaEntity comment5 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글5", 5)); + CommentJpaEntity comment6 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글6", 5)); + CommentJpaEntity comment7 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글7", 5)); + CommentJpaEntity comment8 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글8", 5)); + CommentJpaEntity comment9 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글9", 5)); + CommentJpaEntity comment10 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글10", 5)); + CommentJpaEntity comment11 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글11", 5)); + CommentJpaEntity comment12 = commentJpaRepository.save(TestEntityFactory.createComment(f1, user1, PostType.FEED, "댓글12", 5)); + + feedJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE posts SET created_at = ? WHERE post_id = ?", + Timestamp.valueOf(base.minusMinutes(50)), f1.getPostId()); + + commentJpaRepository.flush(); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(40)), comment1.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(35)), comment2.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(30)), comment3.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(25)), comment4.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(20)), comment5.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(15)), comment6.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(10)), comment7.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(9)), comment8.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(8)), comment9.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(7)), comment10.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(6)), comment11.getCommentId()); + jdbcTemplate.update( + "UPDATE comments SET created_at = ? WHERE comment_id = ?", + Timestamp.valueOf(base.minusMinutes(5)), comment12.getCommentId()); + + //when //then + MvcResult firstResult = mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("postType", FEED_POST_TYPE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nextCursor", notNullValue())) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.commentList", hasSize(10))) + /** + * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 + * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + */ + // 루트 댓글 정렬 확인 + .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment12.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment11.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[2].commentId", is(comment10.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[3].commentId", is(comment9.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[4].commentId", is(comment8.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[5].commentId", is(comment7.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[6].commentId", is(comment6.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[7].commentId", is(comment5.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[8].commentId", is(comment4.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[9].commentId", is(comment3.getCommentId().intValue()))) + .andReturn(); + + String responseBody = firstResult.getResponse().getContentAsString(); + String nextCursor = JsonPath.read(responseBody, "$.data.nextCursor"); + + mockMvc.perform(get("/comments/{postId}", f1.getPostId().intValue()) + .requestAttr("userId", me.getUserId()) + .param("postType", FEED_POST_TYPE) + .param("cursor", nextCursor)) // 2페이지 요청 + + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.commentList", hasSize(2))) + /** + * 루트 댓글 : 댓글 정보, 댓글 작성자 정보, 좋아요 수, 삭제된 댓글 여부 등을 반환한다 + * 자식 댓글 : 부모 댓글의 작성자 정보(@ 표시를 위함), 댓글 정보, 댓글 작성자 정보, 좋아요 수 등을 반환한다 + */ + // 루트 댓글 정렬 확인 + .andExpect(jsonPath("$.data.commentList[0].commentId", is(comment2.getCommentId().intValue()))) + .andExpect(jsonPath("$.data.commentList[1].commentId", is(comment1.getCommentId().intValue()))); + } +} diff --git a/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllControllerTest.java b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllControllerTest.java new file mode 100644 index 000000000..c77c1c4fe --- /dev/null +++ b/src/test/java/konkuk/thip/comment/adapter/in/web/CommentShowAllControllerTest.java @@ -0,0 +1,40 @@ +package konkuk.thip.comment.adapter.in.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static konkuk.thip.common.exception.code.ErrorCode.POST_TYPE_NOT_MATCH; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 댓글 조회 api controller 단위 테스트") +class CommentShowAllControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("댓글 조회 api 요청 시 request param의 PostType 값이 유효하지 않는 경우, 400 에러가 발생한다.") + void comment_show_all_post_type_invalid() throws Exception { + //given + String invalidPostType = "invalidPostType"; + + //when //then + mockMvc.perform(get("/comments/{postId}", 1L) + .requestAttr("userId", 1L) + .param("postType", invalidPostType)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_TYPE_NOT_MATCH.getCode())) + .andExpect(jsonPath("$.message", containsString(POST_TYPE_NOT_MATCH.getMessage()))); + } +} diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index dd9fc44fe..c68069fcc 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -185,6 +185,20 @@ public static CommentJpaEntity createComment(PostJpaEntity post, UserJpaEntity u .build(); } + /** + * 댓글 내용, likeCount 커스텀 + */ + public static CommentJpaEntity createComment(PostJpaEntity post, UserJpaEntity user,PostType postType, String content, int likeCount) { + return CommentJpaEntity.builder() + .content(content) + .postJpaEntity(post) + .userJpaEntity(user) + .likeCount(likeCount) + .reportCount(0) + .postType(postType) + .build(); + } + public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEntity user,PostType postType,CommentJpaEntity parentComment) { return CommentJpaEntity.builder() .content("댓글 내용") @@ -197,6 +211,21 @@ public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEnt .build(); } + /** + * 자식 댓글 내용, likeCount 커스텀 + */ + public static CommentJpaEntity createReplyComment(PostJpaEntity post, UserJpaEntity user, PostType postType, CommentJpaEntity parentComment, String content, int likeCount) { + return CommentJpaEntity.builder() + .content(content) + .postJpaEntity(post) + .userJpaEntity(user) + .likeCount(likeCount) + .reportCount(0) + .postType(postType) + .parent(parentComment) + .build(); + } + public static CommentLikeJpaEntity createCommentLike(CommentJpaEntity comment, UserJpaEntity user) { return CommentLikeJpaEntity.builder() .userJpaEntity(user)