diff --git a/backend/src/main/java/org/juniortown/backend/BackendApplication.java b/backend/src/main/java/org/juniortown/backend/BackendApplication.java index dfe15b9..a0aa197 100644 --- a/backend/src/main/java/org/juniortown/backend/BackendApplication.java +++ b/backend/src/main/java/org/juniortown/backend/BackendApplication.java @@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableJpaAuditing +@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider") @EnableScheduling public class BackendApplication { diff --git a/backend/src/main/java/org/juniortown/backend/comment/controller/CommentController.java b/backend/src/main/java/org/juniortown/backend/comment/controller/CommentController.java new file mode 100644 index 0000000..6150807 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/controller/CommentController.java @@ -0,0 +1,51 @@ +package org.juniortown.backend.comment.controller; + +import org.juniortown.backend.comment.dto.request.CommentCreateRequest; +import org.juniortown.backend.comment.dto.request.CommentUpdateRequest; +import org.juniortown.backend.comment.dto.response.CommentCreateResponse; +import org.juniortown.backend.comment.service.CommentService; +import org.juniortown.backend.user.dto.CustomUserDetails; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class CommentController { + private final CommentService commentService; + @PostMapping("/comments") + public ResponseEntity createComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody CommentCreateRequest commentCreateRequest) { + Long userId = customUserDetails.getUserId(); + + CommentCreateResponse response = commentService.createComment(userId, commentCreateRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity commentDeleteRequest(@AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long commentId) { + Long userId = customUserDetails.getUserId(); + commentService.deleteComment(userId, commentId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + @PatchMapping("/comments/{commentId}") + public ResponseEntity commentUpdateRequest(@AuthenticationPrincipal CustomUserDetails customUserDetails, + @PathVariable Long commentId, @Valid @RequestBody CommentUpdateRequest commentUpdateRequest) { + Long userId = customUserDetails.getUserId(); + commentService.updateComment(userId, commentId, commentUpdateRequest); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentCreateRequest.java b/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..789d6cd --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentCreateRequest.java @@ -0,0 +1,23 @@ +package org.juniortown.backend.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class CommentCreateRequest { + @NotNull(message = "게시글 ID를 입력해주세요.") + private final Long postId; + private final Long parentId; + @NotBlank(message = "댓글 내용을 입력해주세요.") + private final String content; + + @Builder + public CommentCreateRequest(Long postId, Long parentId, String content) { + this.postId = postId; + this.parentId = parentId; + this.content = content; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentUpdateRequest.java b/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentUpdateRequest.java new file mode 100644 index 0000000..2da534e --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/dto/request/CommentUpdateRequest.java @@ -0,0 +1,15 @@ +package org.juniortown.backend.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CommentUpdateRequest { + @NotBlank(message = "댓글 내용을 입력해주세요.") + private final String content; + @Builder + public CommentUpdateRequest(String content) { + this.content = content; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentCreateResponse.java b/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentCreateResponse.java new file mode 100644 index 0000000..9ae0fe8 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentCreateResponse.java @@ -0,0 +1,40 @@ +package org.juniortown.backend.comment.dto.response; + +import java.time.LocalDateTime; + +import org.juniortown.backend.comment.entity.Comment; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CommentCreateResponse { + private final Long commentId; + private final String content; + private final Long postId; + private final Long parentId; + private final Long userId; + private final String username; + private final LocalDateTime createdAt; + @Builder + public CommentCreateResponse(Long commentId, String content, Long postId, Long parentId, Long userId, String username, LocalDateTime createdAt) { + this.commentId = commentId; + this.content = content; + this.postId = postId; + this.parentId = parentId; + this.userId = userId; + this.username = username; + this.createdAt = createdAt; + } + public static CommentCreateResponse from(Comment comment) { + return CommentCreateResponse.builder() + .commentId(comment.getId()) + .content(comment.getContent()) + .postId(comment.getPost().getId()) + .parentId(comment.getParent() != null ? comment.getParent().getId() : null) + .userId(comment.getUser().getId()) + .username(comment.getUser().getName()) + .createdAt(comment.getCreatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentsInPost.java b/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentsInPost.java new file mode 100644 index 0000000..0b22111 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/dto/response/CommentsInPost.java @@ -0,0 +1,54 @@ +package org.juniortown.backend.comment.dto.response; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.juniortown.backend.comment.entity.Comment; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CommentsInPost { + private final Long commentId; + private final Long postId; + private final Long parentId; + private final Long userId; + private final String content; + private final String username; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final LocalDateTime deletedAt; + private final List children = new ArrayList<>(); + @Builder + public CommentsInPost(Long commentId, Long postId, Long parentId, Long userId, String content, String username, LocalDateTime createdAt, + LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.commentId = commentId; + this.postId = postId; + this.parentId = parentId; + this.userId = userId; + this.content = content; + this.username = username; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public static CommentsInPost from(Comment comment) { + return CommentsInPost.builder() + .commentId(comment.getId()) + .postId(comment.getPost().getId()) + // 근데 이게 꼭 필요한가? userId 쓰는 경우가 생각이 안나 + .userId(comment.getUser().getId()) + .parentId(comment.getParent() != null ? comment.getParent().getId() : null) + .content(comment.getContent()) + // 이게 원래는 user 테이블을 조회해야하는데 comment에 username이 있으니까 + // 조회를 안해도 되는 사기 스킬 + .username(comment.getUsername()) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .deletedAt(comment.getDeletedAt()) + .build(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/entity/Comment.java b/backend/src/main/java/org/juniortown/backend/comment/entity/Comment.java new file mode 100644 index 0000000..9f9b6ea --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/entity/Comment.java @@ -0,0 +1,83 @@ +package org.juniortown.backend.comment.entity; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.juniortown.backend.comment.dto.request.CommentUpdateRequest; +import org.juniortown.backend.comment.exception.AlreadyDeletedCommentException; +import org.juniortown.backend.entity.BaseTimeEntity; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.user.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Comment extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "parent_id") + private Comment parent; + + @Lob + private String content; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "username", nullable = false) + private String username; + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + // 임시로 작성 + @OneToMany(mappedBy = "parent") + private List replies = new ArrayList<>(); + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder + public Comment(Comment parent, String content, User user,String username, Post post) { + this.parent = parent; + this.content = content; + this.user = user; + this.username = username; + this.post = post; + } + + public void softDelete(Clock clock) { + if(this.deletedAt != null) { + throw new AlreadyDeletedCommentException(); + } + this.deletedAt = LocalDateTime.now(clock); + } + + public void update(CommentUpdateRequest commentUpdateRequest, Clock clock) { + if (this.deletedAt != null) { + throw new AlreadyDeletedCommentException(); + } + this.content = commentUpdateRequest.getContent(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/AlreadyDeletedCommentException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/AlreadyDeletedCommentException.java new file mode 100644 index 0000000..308157b --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/AlreadyDeletedCommentException.java @@ -0,0 +1,17 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; +import org.springframework.http.HttpStatus; + +public class AlreadyDeletedCommentException extends CustomException { + public static final String MESSAGE = "이미 삭제된 댓글입니다."; + + public AlreadyDeletedCommentException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return HttpStatus.NOT_FOUND.value(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/CircularReferenceException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/CircularReferenceException.java new file mode 100644 index 0000000..eb410ea --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/CircularReferenceException.java @@ -0,0 +1,16 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; +import org.springframework.http.HttpStatus; + +public class CircularReferenceException extends CustomException { + public static final String MESSAGE = "댓글 순환 참조 발생!"; + public CircularReferenceException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return HttpStatus.BAD_REQUEST.value(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/CommentNotFoundException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/CommentNotFoundException.java new file mode 100644 index 0000000..a6a2a4e --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/CommentNotFoundException.java @@ -0,0 +1,16 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; + +public class CommentNotFoundException extends CustomException { + public static final String MESSAGE = "해당 댓글을 찾을 수 없습니다."; + + public CommentNotFoundException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return 404; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/DepthLimitTwoException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/DepthLimitTwoException.java new file mode 100644 index 0000000..c4cbf02 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/DepthLimitTwoException.java @@ -0,0 +1,17 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; +import org.springframework.http.HttpStatus; + +public class DepthLimitTwoException extends CustomException { + public static final String MESSAGE = "댓글은 최대 2단계까지만 가능합니다."; + + public DepthLimitTwoException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return HttpStatus.BAD_REQUEST.value(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentDeleteException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentDeleteException.java new file mode 100644 index 0000000..ae6e37f --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentDeleteException.java @@ -0,0 +1,17 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; +import org.springframework.http.HttpStatus; + +public class NoRightForCommentDeleteException extends CustomException { +public static final String MESSAGE = "해당 댓글을 삭제할 권한이 없습니다."; + + public NoRightForCommentDeleteException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return HttpStatus.FORBIDDEN.value(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentUpdateException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentUpdateException.java new file mode 100644 index 0000000..ffd03a2 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/NoRightForCommentUpdateException.java @@ -0,0 +1,17 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; +import org.springframework.http.HttpStatus; + +public class NoRightForCommentUpdateException extends CustomException { +public static final String MESSAGE = "해당 댓글을 수정할 권한이 없습니다."; + + public NoRightForCommentUpdateException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return HttpStatus.FORBIDDEN.value(); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/exception/ParentPostMismatchException.java b/backend/src/main/java/org/juniortown/backend/comment/exception/ParentPostMismatchException.java new file mode 100644 index 0000000..020ac7e --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/exception/ParentPostMismatchException.java @@ -0,0 +1,16 @@ +package org.juniortown.backend.comment.exception; + +import org.juniortown.backend.exception.CustomException; + +public class ParentPostMismatchException extends CustomException { + public static final String MESSAGE = "부모 댓글의 게시글과 대댓글이 속한 게시글이 일치하지 않습니다."; + + public ParentPostMismatchException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return 400; // Bad Request + } +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java b/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java new file mode 100644 index 0000000..0e0d862 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java @@ -0,0 +1,10 @@ +package org.juniortown.backend.comment.repository; + +import java.util.List; + +import org.juniortown.backend.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + List findByPostIdOrderByCreatedAtAsc(Long postId); +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/service/CommentService.java b/backend/src/main/java/org/juniortown/backend/comment/service/CommentService.java new file mode 100644 index 0000000..3790089 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/service/CommentService.java @@ -0,0 +1,142 @@ +package org.juniortown.backend.comment.service; + +import java.time.Clock; + +import org.juniortown.backend.comment.dto.request.CommentCreateRequest; +import org.juniortown.backend.comment.dto.request.CommentUpdateRequest; +import org.juniortown.backend.comment.dto.response.CommentCreateResponse; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.exception.AlreadyDeletedCommentException; +import org.juniortown.backend.comment.exception.CircularReferenceException; +import org.juniortown.backend.comment.exception.CommentNotFoundException; +import org.juniortown.backend.comment.exception.DepthLimitTwoException; +import org.juniortown.backend.comment.exception.NoRightForCommentDeleteException; +import org.juniortown.backend.comment.exception.NoRightForCommentUpdateException; +import org.juniortown.backend.comment.exception.ParentPostMismatchException; +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.exception.PostNotFoundException; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.exception.UserNotFoundException; +import org.juniortown.backend.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class CommentService { + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final Clock clock; + + public CommentCreateResponse createComment(Long userId, CommentCreateRequest commentCreateRequest) { + String content = commentCreateRequest.getContent(); + Long postId = commentCreateRequest.getPostId(); + Long parentId = commentCreateRequest.getParentId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.info("User not found with id: {}", userId); + return new UserNotFoundException(); + }); + Post post = postRepository.findById(postId) + .orElseThrow(() -> { + log.info("Post not found with id: {}", postId); + return new PostNotFoundException(); + }); + + // 최상위 댓글인 경우 + Comment parentComment = null; + if (parentId != null) { + parentComment = commentRepository.findById(parentId) + .orElseThrow(() -> { + log.info("Parent comment not found with id: {}", parentId); + return new CommentNotFoundException(); + }); + if (parentComment.getDeletedAt() != null) { + log.info("Parent comment with id {} is already deleted.", parentId); + throw new AlreadyDeletedCommentException(); + } + if(parentComment.getParent() != null) { + log.info("depth는 2로 제한됩니다. 현재 parentComment의 parent가 존재합니다."); + throw new DepthLimitTwoException(); + } + if(!parentComment.getPost().getId().equals(postId)) { + log.info("comment의 postId {}가 post ID {}와 다름.", parentComment.getPost().getId(), postId); + throw new ParentPostMismatchException(); + } + } + + + Comment comment = Comment.builder() + .content(content) + .user(user) + .username(user.getName()) + .post(post) + .parent(parentComment) + .build(); + + Comment saveComment = commentRepository.save(comment); + // 순환 참조 방지 로직 + validateNoCircularReference(parentComment, saveComment.getId()); + return CommentCreateResponse.from(saveComment); + } + + public void deleteComment(Long userId, Long commentId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.info("User not found with id: {}", userId); + return new UserNotFoundException(); + }); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> { + log.info("Comment not found with id: {}", commentId); + return new CommentNotFoundException(); + }); + + if (!comment.getUser().getId().equals(user.getId())) { + log.info("User {} does not have permission to delete comment {}", userId, commentId); + throw new NoRightForCommentDeleteException(); + } + + comment.softDelete(clock); + } + + public void updateComment(Long userId, Long commentId, CommentUpdateRequest commentUpdateRequest) { + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.info("User not found with id: {}", userId); + return new UserNotFoundException(); + }); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> { + log.info("Comment not found with id: {}", commentId); + return new CommentNotFoundException(); + }); + + if (!comment.getUser().getId().equals(user.getId())) { + log.info("User {} does not have permission to update comment {}", userId, commentId); + throw new NoRightForCommentUpdateException(); + } + comment.update(commentUpdateRequest, clock); + // 순환 참조 방지 로직 + validateNoCircularReference(comment.getParent(), commentId); + } + private void validateNoCircularReference(Comment parent, Long childId) { + Comment current = parent; + while (current != null) { + if (current.getId().equals(childId)) { + throw new CircularReferenceException(); + } + current = current.getParent(); + } + } + +} diff --git a/backend/src/main/java/org/juniortown/backend/comment/service/CommentTreeBuilder.java b/backend/src/main/java/org/juniortown/backend/comment/service/CommentTreeBuilder.java new file mode 100644 index 0000000..d950e04 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/comment/service/CommentTreeBuilder.java @@ -0,0 +1,35 @@ +package org.juniortown.backend.comment.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.juniortown.backend.comment.dto.response.CommentsInPost; +import org.juniortown.backend.comment.entity.Comment; + +public class CommentTreeBuilder { + public static List build(List comments) { + if(comments.isEmpty()) return List.of(); + List roots = new ArrayList<>(); + Map map = new HashMap<>(); + // 1. 모든 댓글을 Map에 넣는다 + for (Comment c : comments) { + CommentsInPost comment = CommentsInPost.from(c); + map.put(comment.getCommentId(), comment); + } + // 2. 부모-자식 관계 설정 + for (Comment c : comments) { + CommentsInPost comment = map.get(c.getId()); + if (c.getParent() == null) { + roots.add(comment); + } else { + CommentsInPost parent = map.get(c.getParent().getId()); + if (parent != null) { + parent.getChildren().add(comment); + } + } + } + return roots; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/config/AuditingDateTimeProvider.java b/backend/src/main/java/org/juniortown/backend/config/AuditingDateTimeProvider.java new file mode 100644 index 0000000..d86fd91 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/config/AuditingDateTimeProvider.java @@ -0,0 +1,22 @@ +package org.juniortown.backend.config; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Optional; + +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component("auditingDateTimeProvider") +@RequiredArgsConstructor +public class AuditingDateTimeProvider implements DateTimeProvider { + private final Clock clock; + + @Override + public Optional getNow() { + return Optional.of(LocalDateTime.now(clock)); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/config/TimeConfig.java b/backend/src/main/java/org/juniortown/backend/config/TimeConfig.java index d66b95a..984faaf 100644 --- a/backend/src/main/java/org/juniortown/backend/config/TimeConfig.java +++ b/backend/src/main/java/org/juniortown/backend/config/TimeConfig.java @@ -4,8 +4,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; @Configuration +@Profile("!test") public class TimeConfig { @Bean public Clock clock() { diff --git a/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java b/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java new file mode 100644 index 0000000..de7e2db --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java @@ -0,0 +1,52 @@ +package org.juniortown.backend.dummyData; + +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.repository.UserRepository; +import org.juniortown.backend.user.request.SignUpDTO; +import org.juniortown.backend.user.service.AuthService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Profile("!test") +public class DummyDataInit implements CommandLineRunner { + private final UserRepository userRepository; + private final PostRepository postRepository; + private final AuthService authService; + + @Override + public void run(String... args) throws Exception { + // 회원 가입 + SignUpDTO signUpDTO = SignUpDTO.builder() + .email("test@email.com") + .username("testUser") + .password("1234") + .build(); + + authService.signUp(signUpDTO); + + User user = userRepository.findByEmail(signUpDTO.getEmail()) + .orElseThrow(() -> new RuntimeException("User not found")); + + // 게시글 생성 + Post testPost1 = Post.builder() + .title("Dummy Post Title") + .content("This is a dummy post content.") + .user(user) + .build(); + postRepository.save(testPost1); + + Post testPost2 = Post.builder() + .title("Another Dummy Post Title") + .content("This is another dummy post content.") + .user(user) + .build(); + postRepository.save(testPost2); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java index d255931..14f0449 100644 --- a/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java +++ b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java @@ -9,7 +9,7 @@ import lombok.Setter; import lombok.ToString; -@Setter +@Setter //?? @Getter @ToString public class PostCreateRequest { @@ -23,12 +23,6 @@ public PostCreateRequest(String title, String content) { this.content = content; } - // public void validate() { - // if (title.contains("바보")) { - // throw new InvalidRequest("title", "제목에 바보를 포함할 수 없습니다."); - // } - // } - public Post toEntity(User user) { return Post.builder() .title(title) diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostDetailResponse.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostDetailResponse.java index 8d4ec43..cc30427 100644 --- a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostDetailResponse.java +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostDetailResponse.java @@ -1,6 +1,9 @@ package org.juniortown.backend.post.dto.response; import java.time.LocalDateTime; +import java.util.List; + +import org.juniortown.backend.comment.dto.response.CommentsInPost; import lombok.Builder; import lombok.Getter; @@ -15,13 +18,14 @@ public class PostDetailResponse { private final Long likeCount; private final Boolean isLiked; private final Long readCount; + private final List comments; private final LocalDateTime createdAt; private final LocalDateTime updatedAt; private final LocalDateTime deletedAt; @Builder public PostDetailResponse(Long id, String title, String content, Long userId, String userName, Long likeCount, - Boolean isLiked, Long readCount, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + Boolean isLiked, Long readCount, List comments, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { this.id = id; this.title = title; this.content = content; @@ -30,6 +34,7 @@ public PostDetailResponse(Long id, String title, String content, Long userId, St this.likeCount = likeCount; this.isLiked = isLiked; this.readCount = readCount; + this.comments = comments; this.createdAt = createdAt; this.updatedAt = updatedAt; this.deletedAt = deletedAt; diff --git a/backend/src/main/java/org/juniortown/backend/post/exception/PostNotFoundException.java b/backend/src/main/java/org/juniortown/backend/post/exception/PostNotFoundException.java index 4007034..80f74dd 100644 --- a/backend/src/main/java/org/juniortown/backend/post/exception/PostNotFoundException.java +++ b/backend/src/main/java/org/juniortown/backend/post/exception/PostNotFoundException.java @@ -3,7 +3,7 @@ import org.juniortown.backend.exception.CustomException; public class PostNotFoundException extends CustomException { - private static final String MESSAGE = "해당 게시글을 찾을 수 없습니다."; + public static final String MESSAGE = "해당 게시글을 찾을 수 없습니다."; public PostNotFoundException() { super(MESSAGE); } diff --git a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java index fabf9de..9fed72d 100644 --- a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java +++ b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -27,6 +26,4 @@ public interface PostRepository extends JpaRepository, PostRepositor "GROUP BY p.id, p.title, p.readCount, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt" ) Page findAllWithLikeCount(@Param("userId") Long userId, Pageable pageable); - - } diff --git a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java index 3b2b445..5216491 100644 --- a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java +++ b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java @@ -6,6 +6,10 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.juniortown.backend.comment.dto.response.CommentsInPost; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.comment.service.CommentTreeBuilder; import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.post.dto.request.PostCreateRequest; @@ -40,6 +44,7 @@ public class PostService { private final UserRepository userRepository; private final LikeRepository likeRepository; private final ViewCountService viewCountService; + private final CommentRepository commentRepository; private final Clock clock; private final RedisTemplate redisTemplate; private final static int PAGE_SIZE = 10; @@ -136,6 +141,11 @@ public PostDetailResponse getPost(Long postId, String viewerId) { } Long likeCount = likeRepository.countByPostId(postId); Long redisReadCount = viewCountService.readCountUp(viewerId, postId.toString()); + + // 댓글 조회 로직 + List comments = commentRepository.findByPostIdOrderByCreatedAtAsc(postId); + List commentTree = CommentTreeBuilder.build(comments); + return PostDetailResponse.builder() .id(post.getId()) .title(post.getTitle()) @@ -145,6 +155,7 @@ public PostDetailResponse getPost(Long postId, String viewerId) { .likeCount(likeCount) .isLiked(like.isPresent()) .readCount(post.getReadCount() + redisReadCount) + .comments(commentTree) .createdAt(post.getCreatedAt()) .updatedAt(post.getUpdatedAt()) .deletedAt(post.getDeletedAt()) diff --git a/backend/src/main/java/org/juniortown/backend/user/exception/UserNotFoundException.java b/backend/src/main/java/org/juniortown/backend/user/exception/UserNotFoundException.java index 023534f..309da05 100644 --- a/backend/src/main/java/org/juniortown/backend/user/exception/UserNotFoundException.java +++ b/backend/src/main/java/org/juniortown/backend/user/exception/UserNotFoundException.java @@ -4,7 +4,7 @@ import org.springframework.http.HttpStatus; public class UserNotFoundException extends CustomException { - private static final String MESSAGE = "해당 사용자를 찾을 수 없습니다."; + public static final String MESSAGE = "해당 사용자를 찾을 수 없습니다."; public UserNotFoundException() { super(MESSAGE); } diff --git a/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java b/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..1872be1 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java @@ -0,0 +1,571 @@ +package org.juniortown.backend.comment.controller; + +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.assertj.core.api.Assertions; +import org.juniortown.backend.comment.dto.request.CommentCreateRequest; +import org.juniortown.backend.comment.dto.request.CommentUpdateRequest; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.exception.AlreadyDeletedCommentException; +import org.juniortown.backend.comment.exception.CommentNotFoundException; +import org.juniortown.backend.comment.exception.DepthLimitTwoException; +import org.juniortown.backend.comment.exception.NoRightForCommentDeleteException; +import org.juniortown.backend.comment.exception.NoRightForCommentUpdateException; +import org.juniortown.backend.comment.exception.ParentPostMismatchException; +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.config.RedisTestConfig; +import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.exception.PostNotFoundException; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.dto.LoginDTO; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.exception.UserNotFoundException; +import org.juniortown.backend.user.jwt.JWTUtil; +import org.juniortown.backend.user.repository.UserRepository; +import org.juniortown.backend.user.request.SignUpDTO; +import org.juniortown.backend.user.service.AuthService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +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.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. +@Import({RedisTestConfig.class, SyncConfig.class, TestClockConfig.class}) +@Transactional +class CommentControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private AuthService authService; + @Autowired + private JWTUtil jwtUtil; + @Autowired + private Clock clock; + private static String jwt; + private User testUser; + private Post post; + + private final static Long ID_NOT_EXIST = 999999L; + private final static String COMMENT_CONTENT_NOT_EMPTY = "댓글 내용을 입력해주세요."; + private final static String POST_ID_NOT_EMPTY = "게시글 ID를 입력해주세요."; + + @BeforeEach + public void init() throws Exception { + UUID uuid = UUID.randomUUID(); + String email = uuid + "@naver.com"; + SignUpDTO signUpDTO = SignUpDTO.builder() + .email(email) + .password("1234") + .username("테스터") + .build(); + + LoginDTO loginDTO = LoginDTO.builder() + .email(signUpDTO.getEmail()) + .password(signUpDTO.getPassword()) + .build(); + authService.signUp(signUpDTO); + testUser = userRepository.findByEmail(email).get(); + + // 로그인 후 JWT 토큰을 발급받는다. + mockMvc.perform(MockMvcRequestBuilders.post("/api/auth/login") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginDTO)) + ) + .andExpect(status().isOk()) + .andDo(result -> { + jwt = result.getResponse().getHeader("Authorization"); + }); + + // 게시글을 하나 생성한다. + post = postRepository.save( + Post.builder() + .title("테스트 게시글") + .content("테스트 내용입니다.") + .user(testUser) + .build() + ); + postRepository.save(post); + } + + @Test + @DisplayName("댓글 생성 성공") + void create_comment_success() throws Exception { + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(null) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.commentId").exists()) + .andExpect(jsonPath("$.content").value("테스트 댓글")) + .andExpect(jsonPath("$.postId").value(post.getId())) + .andExpect(jsonPath("$.parentId").doesNotExist()) + .andExpect(jsonPath("$.userId").value(testUser.getId())) + .andExpect(jsonPath("$.username").value(testUser.getName())) + .andExpect(jsonPath("$.createdAt").exists()) + .andDo(print()); + } + + @Test + @DisplayName("대댓글 생성 성공") + void create_child_comment_success() throws Exception { + Comment parentComment = Comment.builder() + .content("부모 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + parentComment = commentRepository.save(parentComment); + + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(parentComment.getId()) // 부모 댓글 ID + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.commentId").exists()) + .andExpect(jsonPath("$.content").value("테스트 댓글")) + .andExpect(jsonPath("$.postId").value(post.getId())) + .andExpect(jsonPath("$.parentId").value(parentComment.getId())) + .andExpect(jsonPath("$.userId").value(testUser.getId())) + .andExpect(jsonPath("$.username").value(testUser.getName())) + .andExpect(jsonPath("$.createdAt").exists()) + .andDo(print()); + } + + @Test + @DisplayName("댓글 생성 실패 - 게시글이 존재하지 않음") + void create_comment_fail_with_no_post() throws Exception { + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(ID_NOT_EXIST) + .parentId(null) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(PostNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 생성 실패 - 유저가 존재하지 않음") + void create_comment_fail_with_no_user() throws Exception { + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(null) // 최상위 댓글인 경우 null + .build(); + userRepository.deleteById(testUser.getId()); // 유저 삭제 + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(UserNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("대댓글 생성 실패 - 부모 댓글과 자식 댓글의 게시글이 다름") + void create_comment_fail_with_not_same_post() throws Exception { + Post dummyPost = Post.builder() + .title("다른 게시글") + .content("다른 게시글 내용입니다.") + .user(testUser) + .build(); + Post savedDummyPost = postRepository.save(dummyPost); + + Comment parentComment = Comment.builder() + .content("부모 댓글") + .post(savedDummyPost) + .user(testUser) + .username(testUser.getName()) + .build(); + Comment savedParentComment = commentRepository.save(parentComment); + + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(savedParentComment.getId()) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(ParentPostMismatchException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("대댓글 생성 실패 - 부모 댓글이 존재하지 않음.") + void create_comment_fail_with_parent_comment_not_exist() throws Exception { + Comment parentComment = Comment.builder() + .content("부모 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + Comment savedParentComment = commentRepository.save(parentComment); + + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(ID_NOT_EXIST) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(CommentNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 생성 실패 - content가 비어있음") + void create_comment_fail_empty_content() throws Exception { + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("") + .postId(post.getId()) + .parentId(null) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.validation.content").value(COMMENT_CONTENT_NOT_EMPTY)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 생성 실패 - postId가 비어있음") + void create_comment_fail_empty_post_id() throws Exception { + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(null) + .parentId(null) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.validation.postId").value(POST_ID_NOT_EMPTY)) + .andDo(print()); + } + @Test + @DisplayName("대댓글 생성 실패 - 부모 댓글이 삭제됨") + void create_comment_fail_with_parent_comment_is_deleted() throws Exception { + Comment parentComment = Comment.builder() + .content("부모 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + Comment savedParentComment = commentRepository.save(parentComment); + savedParentComment.softDelete(clock); + + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(savedParentComment.getId()) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(AlreadyDeletedCommentException.MESSAGE)) + .andDo(print()); + } + @Test + @DisplayName("대댓글 생성 실패 - 부모 댓글이 대댓글인 경우 (깊이 제한)") + void create_comment_fail_with_parent_comment_has_parent_depth_limit() throws Exception { + Comment grandParentComment = Comment.builder() + .content("할아버지 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + Comment parentComment = Comment.builder() + .parent(grandParentComment) + .content("부모 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + + commentRepository.save(grandParentComment); + Comment savedParentComment = commentRepository.save(parentComment); + + CommentCreateRequest commentRequest = CommentCreateRequest.builder() + .content("테스트 댓글") + .postId(post.getId()) + .parentId(parentComment.getId()) // 최상위 댓글인 경우 null + .build(); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/comments") + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentRequest)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(DepthLimitTwoException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 삭제 성공") + void delete_comment_success() throws Exception { + // given + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + comment = commentRepository.save(comment); + + // when then + mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{commentId}", comment.getId()) + .header("Authorization", jwt) + ) + .andExpect(status().isNoContent()) + .andDo(print()); + + Comment savedComment = commentRepository.findById(comment.getId()).get(); + Assertions.assertThat(savedComment.getDeletedAt()).isEqualTo(LocalDateTime.now(clock)); + } + + @Test + @DisplayName("댓글 삭제 실패 - 유저가 존재하지 않음") + void delete_comment_fail_with_no_user() throws Exception { + // given + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + comment = commentRepository.save(comment); + userRepository.deleteById(testUser.getId()); // 유저 삭제 + + // when then + mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{commentId}", comment.getId()) + .header("Authorization", jwt) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(UserNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 삭제 실패 - 댓글이 존재하지 않음") + void delete_comment_fail_with_no_comment() throws Exception { + // given + Long nonExistentCommentId = ID_NOT_EXIST; + + // when then + mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{commentId}", nonExistentCommentId) + .header("Authorization", jwt) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(CommentNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 삭제 실패 - 유저가 댓글을 삭제할 권한이 없음") + void delete_comment_fail_with_no_right_for_deletion() throws Exception { + User otherUser = User.builder() + .name("댓글 주인") + .email("comment@email.com") + .password("1234") + .build(); + User CommentOwner = userRepository.save(otherUser); + + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(CommentOwner) + .username(CommentOwner.getName()) + .build(); + comment = commentRepository.save(comment); + + // when then + mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{commentId}", comment.getId()) + .header("Authorization", jwt) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(NoRightForCommentDeleteException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 수정 성공") + void comment_update_success() throws Exception { + // given + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + comment = commentRepository.save(comment); + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("수정된 댓글 내용") + .build(); + // when then + mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{commentId}", comment.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + ) + .andExpect(status().isNoContent()) + .andDo(print()); + Comment savedComment = commentRepository.findById(comment.getId()).get(); + Assertions.assertThat(savedComment.getUpdatedAt()).isEqualTo(LocalDateTime.now(clock)); + Assertions.assertThat(savedComment.getContent()).isEqualTo("수정된 댓글 내용"); + } + + @Test + @DisplayName("댓글 수정 실패 - 유저가 존재하지 않음") + void comment_update_fail_with_no_user() throws Exception { + // given + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(testUser) + .username(testUser.getName()) + .build(); + comment = commentRepository.save(comment); + userRepository.deleteById(testUser.getId()); // 유저 삭제 + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("수정된 댓글 내용") + .build(); + + // when then + mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{commentId}", comment.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(UserNotFoundException.MESSAGE)) + .andDo(print()); + } + @Test + @DisplayName("댓글 수정 실패 - 댓글이 존재하지 않음") + void comment_update_fail_with_no_comment() throws Exception { + // given + Long nonExistentCommentId = ID_NOT_EXIST; + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("수정된 댓글 내용") + .build(); + + // when then + mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{commentId}", nonExistentCommentId) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(CommentNotFoundException.MESSAGE)) + .andDo(print()); + } + + @Test + @DisplayName("댓글 수정 실패 - 유저가 댓글을 수정할 권한이 없음") + void update_comment_fail_with_no_right_for_delete() throws Exception { + // given + User ownerUser = User.builder() + .name("진정한 댓글의 주인") + .password("1234") + .email("owner@email.com") + .build(); + User commentOwner = userRepository.save(ownerUser); + Comment comment = Comment.builder() + .content("테스트 댓글") + .post(post) + .user(ownerUser) + .username(testUser.getName()) + .build(); + comment = commentRepository.save(comment); + + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("수정된 댓글 내용") + .build(); + // when then + mockMvc.perform(MockMvcRequestBuilders.patch("/api/comments/{commentId}", comment.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + .content(objectMapper.writeValueAsString(commentUpdateRequest)) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value(NoRightForCommentUpdateException.MESSAGE)) + .andDo(print()); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/comment/entity/CommentTest.java b/backend/src/test/java/org/juniortown/backend/comment/entity/CommentTest.java new file mode 100644 index 0000000..4a5e2d5 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/comment/entity/CommentTest.java @@ -0,0 +1,29 @@ +package org.juniortown.backend.comment.entity; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CommentTest { + @Test + @DisplayName("Comment 생성 시 기본 값 설정 확인") + void softDelete_setsDeletedAt() { + // given + Comment comment = Comment.builder() + .content("Test comment") + .build(); + Clock fixedClock = Clock.fixed( + Instant.parse("2025-06-20T10:15:30Z"), ZoneOffset.UTC); + // when + comment.softDelete(fixedClock); + + // then + Assertions.assertThat(comment.getDeletedAt()) + .isEqualTo(LocalDateTime.now(fixedClock)); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/comment/service/CommentServiceTest.java b/backend/src/test/java/org/juniortown/backend/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..9e0783b --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/comment/service/CommentServiceTest.java @@ -0,0 +1,476 @@ +package org.juniortown.backend.comment.service; + +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.juniortown.backend.comment.dto.request.CommentCreateRequest; +import org.juniortown.backend.comment.dto.request.CommentUpdateRequest; +import org.juniortown.backend.comment.dto.response.CommentCreateResponse; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.exception.AlreadyDeletedCommentException; +import org.juniortown.backend.comment.exception.CircularReferenceException; +import org.juniortown.backend.comment.exception.CommentNotFoundException; +import org.juniortown.backend.comment.exception.DepthLimitTwoException; +import org.juniortown.backend.comment.exception.NoRightForCommentDeleteException; +import org.juniortown.backend.comment.exception.NoRightForCommentUpdateException; +import org.juniortown.backend.comment.exception.ParentPostMismatchException; +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.exception.PostNotFoundException; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.exception.UserNotFoundException; +import org.juniortown.backend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class CommentServiceTest { + @InjectMocks + private CommentService commentService; + @Mock + private CommentRepository commentRepository; + @Mock + private UserRepository userRepository; + @Mock + private PostRepository postRepository; + @Mock + private Clock clock; + @Mock + private Post post; + @Mock + private User user; + @Mock + private Comment comment; + static final Long USER_ID = 3L; + static final Long POST_ID = 1L; + static final Long COMMENT_ID = 5L; + static final String USERNAME = "testUser"; + + + @Test + @DisplayName("댓글 생성 성공 테스트") + void create_comment_success() { + // given + Long parentId = null; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(user.getName()).thenReturn(USERNAME); + when(user.getId()).thenReturn(USER_ID); + when(post.getId()).thenReturn(POST_ID); + + when(commentRepository.save(any())).thenReturn(comment); + when(comment.getId()).thenReturn(COMMENT_ID); + when(comment.getContent()).thenReturn(content); + when(comment.getPost()).thenReturn(post); + when(comment.getParent()).thenReturn(null); + when(comment.getUser()).thenReturn(user); + + // when + CommentCreateResponse response = commentService.createComment(USER_ID, commentCreateRequest); + + // then + verify(commentRepository).save(any()); + Assertions.assertThat(response.getContent()).isEqualTo(content); + Assertions.assertThat(response.getPostId()).isEqualTo(POST_ID); + Assertions.assertThat(response.getParentId()).isNull(); + Assertions.assertThat(response.getUserId()).isEqualTo(USER_ID); + Assertions.assertThat(response.getUsername()).isEqualTo(user.getName()); + } + + @Test + @DisplayName("대댓글 생성 성공 테스트") + void create_child_comment_success() { + // given + Long parentId = 2L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + Comment parentComment = mock(Comment.class); + when(parentComment.getId()).thenReturn(parentId); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parentComment)); + when(parentComment.getPost()).thenReturn(post); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(user.getName()).thenReturn(USERNAME); + when(user.getId()).thenReturn(USER_ID); + when(post.getId()).thenReturn(POST_ID); + + when(commentRepository.save(any())).thenReturn(comment); + when(comment.getId()).thenReturn(COMMENT_ID); + when(comment.getContent()).thenReturn(content); + when(comment.getPost()).thenReturn(post); + when(comment.getParent()).thenReturn(null); + when(comment.getUser()).thenReturn(user); + when(comment.getParent()).thenReturn(parentComment); + + // when + CommentCreateResponse response = commentService.createComment(USER_ID, commentCreateRequest); + + // then + verify(commentRepository).save(any()); + Assertions.assertThat(response.getContent()).isEqualTo(content); + Assertions.assertThat(response.getPostId()).isEqualTo(POST_ID); + Assertions.assertThat(response.getUserId()).isEqualTo(USER_ID); + Assertions.assertThat(response.getUsername()).isEqualTo(user.getName()); + Assertions.assertThat(response.getParentId()).isEqualTo(parentId); + } + + @Test + @DisplayName("대댓글 생성 실패 테스트 - 부모 댓글이 다른 게시글에 속함") + void create_child_comment_fail_by_parent_child_comments_have_different_post() { + // given + Long parentId = 2L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + Comment parentComment = mock(Comment.class); + Post otherPost = mock(Post.class); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parentComment)); + when(parentComment.getPost()).thenReturn(otherPost); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + + // when, then + verify(commentRepository, never()).save(any(Comment.class)); + // 예외 터진거 확인은 어떻게 해? + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(ParentPostMismatchException.class) + .hasMessage(ParentPostMismatchException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + @Test + @DisplayName("대댓글 생성 실패 테스트 - 부모 댓글이 대댓글인 경우 depth-2를 위반") + void create_child_comment_fail_by_parent_comment_has_parent_comment() { + // given + Long parentId = 2L; + + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + Comment parentComment = mock(Comment.class); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parentComment)); + when(parentComment.getParent()).thenReturn(mock(Comment.class)); // 부모 댓글이 대댓글인 경우 + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + + + // when, then + verify(commentRepository, never()).save(any(Comment.class)); + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(DepthLimitTwoException.class) + .hasMessage(DepthLimitTwoException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("대댓글 생성 실패 테스트 - 부모 댓글이 알고보니 삭제됨") + void create_child_comment_fail_by_parent_comment_is_deleted() { + // given + Long parentId = 2L; + + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + Comment parentComment = mock(Comment.class); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parentComment)); + when(parentComment.getDeletedAt()).thenReturn(LocalDateTime.now()); // 부모 댓글이 대댓글인 경우 + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + + + // when, then + verify(commentRepository, never()).save(any(Comment.class)); + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(AlreadyDeletedCommentException.class) + .hasMessage(AlreadyDeletedCommentException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("댓글 생성 실패 테스트 - 사용자 존재하지 않음") + void create_child_comment_fail_by_no_user() { + // given + Long parentId = 2L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.ofNullable(null)); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(UserNotFoundException.class) + .hasMessage(UserNotFoundException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + @Test + @DisplayName("댓글 생성 실패 테스트 - 게시글 존재하지 않음") + void create_child_comment_fail_by_no_post() { + // given + Long parentId = 2L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.ofNullable(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage(PostNotFoundException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("댓글 생성 실패 테스트 - 부모 댓글이 존재하지 않음") + void create_child_comment_fail_by_parent_post_not_exist() { + // given + Long parentId = 2L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + + + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(commentRepository.findById(parentId)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(CircularReferenceException.class) + .hasMessage(CircularReferenceException.MESSAGE); + verify(commentRepository, never()).save(any(Comment.class)); + } + @Test + @DisplayName("대댓글 생성 실패 - 댓글 순환 참조 발생") + void create_child_comment_fail_by_circular_reference() { + // given + Long parentId = 1L; + String content = "This is a comment"; + CommentCreateRequest commentCreateRequest = CommentCreateRequest.builder() + .content(content) + .postId(POST_ID) + .parentId(parentId) + .build(); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(post.getId()).thenReturn(POST_ID); + + Comment parentComment = mock(Comment.class); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parentComment)); + when(parentComment.getId()).thenReturn(parentId); + when(parentComment.getPost()).thenReturn(post); + + when(commentRepository.save(any())).thenReturn(comment); + // 조금 억지스러운 순환 참조지만 현재 2-depth에서는 최선인듯. + when(comment.getId()).thenReturn(parentId); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.createComment(USER_ID, commentCreateRequest)) + .isInstanceOf(CircularReferenceException.class) + .hasMessage(CircularReferenceException.MESSAGE); + } + + @Test + @DisplayName("댓글 삭제 성공 테스트") + void delete_comment_success() { + // given + Long commentId = 1L; + Long userId = 2L; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(comment.getUser()).thenReturn(user); + when(user.getId()).thenReturn(userId); + + // when + commentService.deleteComment(userId, commentId); + + // then + verify(comment).softDelete(any(Clock.class)); + } + @Test + @DisplayName("댓글 삭제 실패 테스트 - 사용자가 존재하지 않음") + void delete_comment_fail_by_no_user() { + // given + Long commentId = 1L; + Long userId = 2L; + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.deleteComment(userId, commentId)) + .isInstanceOf(UserNotFoundException.class) + .hasMessage(UserNotFoundException.MESSAGE); + verify(commentRepository, never()).findById(commentId); + } + + @Test + @DisplayName("댓글 삭제 실패 테스트 - 게시글이 존재하지 않음") + void delete_comment_fail_by_no_comment() { + // given + Long commentId = 1L; + Long userId = 2L; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.deleteComment(userId, commentId)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(CommentNotFoundException.MESSAGE); + verify(comment, never()).softDelete(any(Clock.class)); + } + @Test + @DisplayName("댓글 삭제 실패 테스트 - 사용자가 댓글을 작성한 사용자가 아님") + void delete_comment_fail_by_no_right_for_delete() { + // given + Long commentId = 1L; + Long userId = 2L; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(comment.getUser()).thenReturn(mock(User.class)); + when(comment.getUser().getId()).thenReturn(3L); // 다른 사용자 + + // when, then + Assertions.assertThatThrownBy(() -> commentService.deleteComment(userId, commentId)) + .isInstanceOf(NoRightForCommentDeleteException.class) + .hasMessage(NoRightForCommentDeleteException.MESSAGE); + verify(comment, never()).softDelete(any(Clock.class)); + } + @Test + @DisplayName("댓글 수정 성공 테스트") + void update_comment_success() { + // given + Long commentId = 1L; + Long userId = 2L; + String newContent = "Updated comment content"; + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content(newContent) + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(comment.getUser()).thenReturn(user); + when(user.getId()).thenReturn(userId); + + // when + commentService.updateComment(userId, commentId, commentUpdateRequest); + + // then + verify(comment).update(any(CommentUpdateRequest.class), any(Clock.class)); + } + @Test + @DisplayName("댓글 수정 실패 테스트 - 사용자가 존재하지 않음") + void update_comment_fail_by_no_user() { + // given + Long commentId = 1L; + Long userId = 2L; + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("Updated comment content") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.updateComment(userId, commentId, commentUpdateRequest)) + .isInstanceOf(UserNotFoundException.class) + .hasMessage(UserNotFoundException.MESSAGE); + verify(commentRepository, never()).findById(commentId); + } + + @Test + @DisplayName("댓글 수정 실패 테스트 - 댓글이 존재하지 않음") + void update_comment_fail_by_no_comment() { + // given + Long commentId = 1L; + Long userId = 2L; + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("Updated comment content") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThatThrownBy(() -> commentService.updateComment(userId, commentId, commentUpdateRequest)) + .isInstanceOf(CommentNotFoundException.class) + .hasMessage(CommentNotFoundException.MESSAGE); + verify(comment, never()).update(any(CommentUpdateRequest.class), any(Clock.class)); + } + @Test + @DisplayName("댓글 수정 실패 테스트 - 사용자가 댓글 작성자가 아님") + void update_comment_fail_by_no_right_for_update() { + // given + Long commentId = 1L; + Long userId = 2L; + CommentUpdateRequest commentUpdateRequest = CommentUpdateRequest.builder() + .content("Updated comment content") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(comment.getUser()).thenReturn(mock(User.class)); + when(comment.getUser().getId()).thenReturn(3L); // 다른 사용자 + + // when, then + Assertions.assertThatThrownBy(() -> commentService.updateComment(userId, commentId, commentUpdateRequest)) + .isInstanceOf(NoRightForCommentUpdateException.class) + .hasMessage(NoRightForCommentUpdateException.MESSAGE); + verify(comment, never()).update(any(CommentUpdateRequest.class), any(Clock.class)); + } +} + diff --git a/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java b/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java new file mode 100644 index 0000000..c295175 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java @@ -0,0 +1,79 @@ +package org.juniortown.backend.comment.service; + +import static org.juniortown.backend.util.TestDataUtil.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.juniortown.backend.comment.dto.response.CommentsInPost; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CommentTreeBuilderTest { + private User userA; + private User userB; + private Post post; + private Comment parentComment1; + private Comment parentComment2; + private Comment childComment1; + private Comment childComment2; + + @BeforeEach + void setUp() { + userA = createUser(1L, "testA", "testA@gmail.com"); + userB = createUser(2L, "testB", "testB@gmail.com"); + post = createPost(1L, "테스트 게시글", "테스트 게시글 내용", userA); + parentComment1 = createComment(1L, post, userA, "부모 댓글1", null); + parentComment2 = createComment(2L, post, userB, "부모 댓글2", null); + childComment1 = createComment(3L, post, userA, "자식 댓글1", parentComment1); + childComment2 = createComment(4L, post, userB, "자식 댓글2", parentComment2); + } + @Test + @DisplayName("빈 리스트 주입 시 빈 리스트 반환") + void build_emptyList_returnsEmptyList() { + assertTrue(CommentTreeBuilder.build(List.of()).isEmpty()); + } + + @Test + @DisplayName("부모가 없는 댓글들만 주입 시 부모가 없는 댓글들만 반환") + void build_no_parentComments_returns_only_root_comments() { + List comments = CommentTreeBuilder.build(List.of( + parentComment1, parentComment2 + )); + Assertions.assertThat(comments.size()).isEqualTo(2); + Assertions.assertThat(comments.get(0).getChildren()).isEmpty(); + Assertions.assertThat(comments.get(1).getChildren()).isEmpty(); + } + + @Test + @DisplayName("부모가 있는 댓글들만 주입 시 반환 안함.") + void build_only_childComments_returns_nothing() { + List comments = CommentTreeBuilder.build(List.of( + childComment1, childComment2 + )); + Assertions.assertThat(comments).isEmpty(); + } + + @Test + @DisplayName("부모 댓글과 자식 댓글을 주입 시 트리 구조로 반환") + void build_parentAndChildComments_returns_tree_structure() { + List comments = CommentTreeBuilder.build(List.of( + parentComment1, parentComment2, childComment1, childComment2 + )); + Assertions.assertThat(comments.size()).isEqualTo(2); + Assertions.assertThat(comments.get(0).getChildren().size()).isEqualTo(1); + Assertions.assertThat(comments.get(1).getChildren().size()).isEqualTo(1); + Assertions.assertThat(comments.get(0).getChildren().get(0).getContent()).isEqualTo("자식 댓글1"); + Assertions.assertThat(comments.get(1).getChildren().get(0).getContent()).isEqualTo("자식 댓글2"); + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/config/TestClockConfig.java b/backend/src/test/java/org/juniortown/backend/config/TestClockConfig.java new file mode 100644 index 0000000..5594949 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/config/TestClockConfig.java @@ -0,0 +1,18 @@ +package org.juniortown.backend.config; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +@Primary +public class TestClockConfig { + @Bean + public Clock clock() { + return Clock.fixed(Instant.parse("2025-06-20T10:15:30Z"), ZoneOffset.UTC); + } +} diff --git a/backend/src/test/java/org/juniortown/backend/config/TestClockNotMockConfig.java b/backend/src/test/java/org/juniortown/backend/config/TestClockNotMockConfig.java new file mode 100644 index 0000000..699c5c9 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/config/TestClockNotMockConfig.java @@ -0,0 +1,13 @@ +package org.juniortown.backend.config; + +import java.time.Clock; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +@TestConfiguration +public class TestClockNotMockConfig { + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/backend/src/test/java/org/juniortown/backend/controller/AuthControllerTest.java b/backend/src/test/java/org/juniortown/backend/controller/AuthControllerTest.java index 709ce5d..62f57a2 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/AuthControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/controller/AuthControllerTest.java @@ -6,6 +6,7 @@ import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; import org.juniortown.backend.user.dto.LoginDTO; import org.juniortown.backend.user.jwt.JWTUtil; import org.juniortown.backend.user.repository.UserRepository; @@ -25,13 +26,15 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@Import({RedisTestConfig.class,SyncConfig.class}) +@Import({RedisTestConfig.class,SyncConfig.class, TestClockConfig.class}) +@Transactional class AuthControllerTest { @Autowired private MockMvc mockMvc; @@ -44,11 +47,6 @@ class AuthControllerTest { @Autowired private JWTUtil jwtUtil; - @AfterEach - void clean() { - userRepository.deleteAll(); - } - @BeforeEach public void init() { SignUpDTO signUpDTO = SignUpDTO.builder() diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java b/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java index bfb048f..6ba971f 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java +++ b/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java @@ -11,6 +11,8 @@ import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; +import org.juniortown.backend.config.TestClockNotMockConfig; import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.like.service.LikeService; @@ -35,19 +37,26 @@ import org.springframework.context.annotation.Import; import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.redis.testcontainers.RedisContainer; @SpringBootTest @AutoConfigureMockMvc -@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @ActiveProfiles("test") -@Import({RedisTestConfig.class, SyncConfig.class}) +@Import({RedisTestConfig.class, SyncConfig.class, TestClockNotMockConfig.class}) @Transactional +@Testcontainers public class PostControllerPagingTest { @Autowired private MockMvc mockMvc; @@ -70,22 +79,21 @@ public class PostControllerPagingTest { private User testUser2; @Autowired private Clock clock; + @Container + static GenericContainer redis = new RedisContainer(DockerImageName.parse("redis:8.0")) + .withCommand("redis-server --port 6381") + .withExposedPorts(6381); + + + @DynamicPropertySource + static void overrideProps(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } private static final int POST_COUNT = 53; @BeforeEach - void clean_and_init() { - // 게시글 더미 데이터 생성 - for (int i = 0; i < POST_COUNT; i++) { - Post post = Post.builder() - .user(testUser1) - .title("테스트 글 " + (i + 1)) - .content("테스트 내용 " + (i + 1)) - .build(); - postRepository.save(post); - } - } - @BeforeAll - public void init() throws Exception { + void clean_and_init() throws Exception { UUID uuid1 = UUID.randomUUID(); UUID uuid2 = UUID.randomUUID(); String email1 = uuid1 + "@naver.com"; @@ -122,6 +130,15 @@ public void init() throws Exception { .andDo(result -> { jwt = result.getResponse().getHeader("Authorization"); }); + // 게시글 더미 데이터 생성 + for (int i = 0; i < POST_COUNT; i++) { + Post post = Post.builder() + .user(testUser1) + .title("테스트 글 " + (i + 1)) + .content("테스트 내용 " + (i + 1)) + .build(); + postRepository.save(post); + } } @Test @DisplayName("글 목록 조회 - 첫 페이지 조회 성공") @@ -140,7 +157,6 @@ void get_posts_first_page_success() throws Exception { .andExpect(jsonPath("$.hasPrevious").value(false)) .andExpect(jsonPath("$.hasNext").value(true)) // 다음 페이지 있음 .andExpect(jsonPath("$.page").value(0)) // 현재 페이지 - .andExpect(jsonPath("$.content[0].id").value(53)) .andExpect(jsonPath("$.content[0].title").value("테스트 글 53")) // 첫 번째 게시글 제목 .andExpect(jsonPath("$.content[0].userId").value(testUser1.getId())) // 첫 번째 게시글 작성자 ID .andExpect(jsonPath("$.content[0].userName").value(testUser1.getName())) // 첫 번째 게시글 작성자 이름 diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java b/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java index 84abc59..53e8553 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java @@ -14,6 +14,7 @@ import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; import org.juniortown.backend.post.dto.request.PostCreateRequest; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; @@ -56,12 +57,11 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @ActiveProfiles("test") -@Import({RedisTestConfig.class, SyncConfig.class}) +@Import({RedisTestConfig.class, SyncConfig.class, TestClockConfig.class}) @Transactional class PostControllerTest { @Autowired private MockMvc mockMvc; - @Autowired private PostRepository postRepository; @Autowired @@ -73,11 +73,6 @@ class PostControllerTest { @Autowired private JWTUtil jwtUtil; - @BeforeEach - void clean() { - postRepository.deleteAll(); - } - private static String jwt; private User testUser; diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java b/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java index 2e01b4e..d0e7955 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java @@ -5,10 +5,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; -import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.repository.CommentRepository; import org.juniortown.backend.config.RedisTestConfig; +import org.juniortown.backend.config.TestClockConfig; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.juniortown.backend.post.service.ViewCountSyncService; @@ -17,7 +23,6 @@ import org.juniortown.backend.user.repository.UserRepository; import org.juniortown.backend.user.request.SignUpDTO; import org.juniortown.backend.user.service.AuthService; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; @@ -26,7 +31,6 @@ import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; @@ -54,7 +58,7 @@ @ActiveProfiles("test") @Transactional @Testcontainers -@Import(RedisTestConfig.class) +@Import({RedisTestConfig.class, TestClockConfig.class}) public class PostRedisReadControllerTest { @Autowired private MockMvc mockMvc; @@ -63,6 +67,8 @@ public class PostRedisReadControllerTest { @Autowired private UserRepository userRepository; @Autowired + private CommentRepository commentRepository; + @Autowired private ObjectMapper objectMapper; @Autowired private AuthService authService; @@ -73,8 +79,16 @@ public class PostRedisReadControllerTest { private RedisTemplate readCountRedisTemplate; @Autowired private RedissonClient redissonClient; + @Autowired + private Clock clock; Post testPost; + private static final String PARENT_COMMENT_CONTENT1 = "I'm Parent Comment1"; + private static final String PARENT_COMMENT_CONTENT2 = "I'm Parent Comment2"; + private static final String CHILD_COMMENT_CONTENT1 = "I'm Child Comment1"; + private static final String CHILD_COMMENT_CONTENT2 = "I'm Child Comment2"; + + @Container static GenericContainer redis = new RedisContainer(DockerImageName.parse("redis:8.0")) .withCommand("redis-server --port 6380") @@ -126,6 +140,40 @@ public void init() throws Exception { .andDo(result -> { jwt = result.getResponse().getHeader("Authorization"); }); + + // 댓글 생성 + Comment parentComment1 = Comment.builder() + .post(testPost) + .user(testUser) + .username(testUser.getName()) + .content(PARENT_COMMENT_CONTENT1) + .build(); + Comment parentComment2 = Comment.builder() + .post(testPost) + .user(testUser) + .username(testUser.getName()) + .content(PARENT_COMMENT_CONTENT2) + .build(); + Comment childComment1 = Comment.builder() + .post(testPost) + .user(testUser) + .username(testUser.getName()) + .content(CHILD_COMMENT_CONTENT1) + .parent(parentComment1) + .build(); + Comment childComment2 = Comment.builder() + .post(testPost) + .user(testUser) + .username(testUser.getName()) + .content(CHILD_COMMENT_CONTENT2) + .parent(parentComment2) + .build(); + commentRepository.saveAll(List.of( + parentComment1, + parentComment2, + childComment1, + childComment2 + )); } @Test @@ -192,7 +240,13 @@ void dup_key_prevent_read_count_increase() throws Exception { .andExpect(jsonPath("$.likeCount").value(0)) .andExpect(jsonPath("$.createdAt").exists()) .andExpect(jsonPath("$.updatedAt").exists()) - .andExpect(jsonPath("$.deletedAt").doesNotExist()); + .andExpect(jsonPath("$.deletedAt").doesNotExist()) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments.length()").value(2)) + .andExpect(jsonPath("$.comments[0].content").value("I'm Parent Comment1")) + .andExpect(jsonPath("$.comments[0].children[0].content").value("I'm Child Comment1")) + .andExpect(jsonPath("$.comments[1].content").value("I'm Parent Comment2")) + .andExpect(jsonPath("$.comments[1].children[0].content").value("I'm Child Comment2")); } @Test @@ -314,5 +368,89 @@ void view_count_sync_success() { // assertEquals(10L, readCountRedisTemplate.opsForValue().get(key)); // } + @Test + @DisplayName("게시글 조회 성공 With 댓글 트리 구조") + void post_details_read_with_comments_success() throws Exception { + Long postId = testPost.getId(); + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/details/{postId}", postId) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + ) + + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments.length()").value(2)) + .andExpect(jsonPath("$.comments[0].content").value("I'm Parent Comment1")) + .andExpect(jsonPath("$.comments[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[0].deletedAt").isEmpty()) + .andExpect(jsonPath("$.comments[0].createdAt").value(LocalDateTime.now(clock).toString())) + .andExpect(jsonPath("$.comments[0].updatedAt").value(LocalDateTime.now(clock).toString())) + + .andExpect(jsonPath("$.comments[0].children[0].content").value("I'm Child Comment1")) + .andExpect(jsonPath("$.comments[0].children[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[0].children[0].deletedAt").isEmpty()) + .andExpect(jsonPath("$.comments[0].children[0].createdAt").value(LocalDateTime.now(clock).toString())) + .andExpect(jsonPath("$.comments[0].children[0].updatedAt").value(LocalDateTime.now(clock).toString())) + + .andExpect(jsonPath("$.comments[1].content").value("I'm Parent Comment2")) + .andExpect(jsonPath("$.comments[1].username").value("테스터")) + .andExpect(jsonPath("$.comments[1].deletedAt").isEmpty()) + .andExpect(jsonPath("$.comments[1].createdAt").value(LocalDateTime.now(clock).toString())) + .andExpect(jsonPath("$.comments[1].updatedAt").value(LocalDateTime.now(clock).toString())) + + .andExpect(jsonPath("$.comments[1].children[0].content").value("I'm Child Comment2")) + .andExpect(jsonPath("$.comments[1].children[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[1].children[0].deletedAt").isEmpty()) + .andExpect(jsonPath("$.comments[1].children[0].createdAt").value(LocalDateTime.now(clock).toString())) + .andExpect(jsonPath("$.comments[1].children[0].updatedAt").value(LocalDateTime.now(clock).toString())); + + } + + @Test + @DisplayName("게시글 조회 성공 With 댓글 트리 구조 - 댓글 1개 삭제") + void post_details_read_with_one_comment_deleted_success() throws Exception { + Long postId = testPost.getId(); + List comments = commentRepository.findByPostIdOrderByCreatedAtAsc( + postId); + // 나중에 테스트 케이스 더 추가되면 무조건 깨지긴해 + Long childComment2 = comments.stream() + .filter(c -> c.getContent().equals(CHILD_COMMENT_CONTENT2)) + .map(Comment::getId) + .collect(Collectors.toList()) + .get(0); + + mockMvc.perform(MockMvcRequestBuilders.delete("/api/comments/{commentId}", childComment2) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + ) + .andExpect(status().isNoContent()); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/details/{postId}", postId) + .contentType(APPLICATION_JSON) + .header("Authorization", jwt) + ) + + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments.length()").value(2)) + .andExpect(jsonPath("$.comments[0].content").value("I'm Parent Comment1")) + .andExpect(jsonPath("$.comments[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[0].deletedAt").isEmpty()) + + .andExpect(jsonPath("$.comments[0].children[0].content").value("I'm Child Comment1")) + .andExpect(jsonPath("$.comments[0].children[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[0].children[0].deletedAt").isEmpty()) + + .andExpect(jsonPath("$.comments[1].content").value("I'm Parent Comment2")) + .andExpect(jsonPath("$.comments[1].username").value("테스터")) + .andExpect(jsonPath("$.comments[1].deletedAt").isEmpty()) + + .andExpect(jsonPath("$.comments[1].children[0].content").value("I'm Child Comment2")) + .andExpect(jsonPath("$.comments[1].children[0].username").value("테스터")) + .andExpect(jsonPath("$.comments[1].children[0].deletedAt").value(LocalDateTime.now(clock).toString())); + + } } diff --git a/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java index 0e2659f..4407969 100644 --- a/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java @@ -7,6 +7,7 @@ import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.exception.LikeFailureException; import org.juniortown.backend.like.repository.LikeRepository; @@ -37,7 +38,7 @@ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@Import({RedisTestConfig.class, SyncConfig.class}) +@Import({RedisTestConfig.class, SyncConfig.class, TestClockConfig.class}) @TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. @Transactional class LikeControllerTest { diff --git a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java index 700ed42..da0de4b 100644 --- a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java @@ -1,9 +1,7 @@ package org.juniortown.backend.post.entity; -import static org.assertj.core.api.AssertionsForClassTypes.*; import static org.junit.jupiter.api.Assertions.*; -import java.sql.Time; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; @@ -15,7 +13,7 @@ class PostTest { @Test - @DisplayName("Post 생성 시 기본 값 설정 확인") + @DisplayName("Post 삭제 시 기본 값 설정 확인") void softDelete_setsDeletedAt() { // given Post post = Post.builder().title("T").content("C").build(); @@ -27,8 +25,7 @@ void softDelete_setsDeletedAt() { // then assertEquals(post.getDeletedAt(), - LocalDateTime.ofInstant(Instant.parse("2025-06-20T10:15:30Z"), - ZoneOffset.UTC)); + LocalDateTime.now(fixedClock)); } @Test diff --git a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java index 5ea8e82..e9f1c17 100644 --- a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java @@ -1,12 +1,15 @@ package org.juniortown.backend.post.service; import static org.assertj.core.api.Assertions.*; +import static org.juniortown.backend.util.TestDataUtil.*; import static org.mockito.Mockito.*; import java.time.Clock; import java.util.List; import java.util.Optional; +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.repository.CommentRepository; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.post.dto.request.PostCreateRequest; import org.juniortown.backend.post.dto.response.PostDetailResponse; @@ -19,7 +22,6 @@ import org.juniortown.backend.user.entity.User; import org.juniortown.backend.user.exception.UserNotFoundException; import org.juniortown.backend.user.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,6 +52,8 @@ class PostServiceTest { @Mock private ViewCountService viewCountService; @Mock + private CommentRepository commentRepository; + @Mock private Clock clock; @Mock @@ -61,11 +65,6 @@ class PostServiceTest { @Mock private ValueOperations readCountValueOperations; - @BeforeEach - void clear() { - postRepository.deleteAll(); - } - @Test @DisplayName("게시글 생성 성공") void createPost_success() { @@ -272,19 +271,23 @@ void getPost_return_pageDetail() { // given long postId = 1L; long userId = 2L; + long commentId= 1L; String title = "testTitle"; String content = "testContent"; String name = "testName"; - Post post = Post.builder() - .user(user) - .content(content) - .title(title) - .build(); + user = createUser(userId, name, "test@email.com"); + post = createPost(postId, title, content, user); + Comment parentComment1 = createComment(1L, post, user, "부모 댓글1", null); + Comment parentComment2 = createComment(2L, post, user, "부모 댓글2", null); + Comment childComment1 = createComment(3L, post, user, "자식 댓글1", parentComment1); + Comment childComment2 = createComment(4L, post, user, "자식 댓글2", parentComment2); + // when //when(user.getId()).thenReturn(userId); - when(user.getName()).thenReturn(name); when(postRepository.findById(postId)).thenReturn(Optional.ofNullable(post)); + when(commentRepository.findByPostIdOrderByCreatedAtAsc(postId)) + .thenReturn(List.of(parentComment1, parentComment2, childComment1, childComment2)); PostDetailResponse result = postService.getPost(postId, String.valueOf(userId)); @@ -292,6 +295,12 @@ void getPost_return_pageDetail() { assertThat(result.getContent()).isEqualTo(content); assertThat(result.getTitle()).isEqualTo(title); assertThat(result.getUserName()).isEqualTo(name); + assertThat(result.getComments().size()).isEqualTo(2); + assertThat(result.getComments().get(0).getChildren().size()).isEqualTo(1); + assertThat(result.getComments().get(1).getChildren().size()).isEqualTo(1); + assertThat(result.getComments().get(0).getChildren().get(0).getContent()).isEqualTo("자식 댓글1"); + assertThat(result.getComments().get(1).getChildren().get(0).getContent()).isEqualTo("자식 댓글2"); + verify(postRepository).findById(postId); } diff --git a/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java b/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java new file mode 100644 index 0000000..08b1c9c --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java @@ -0,0 +1,38 @@ +package org.juniortown.backend.util; + +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.user.entity.User; +import org.springframework.test.util.ReflectionTestUtils; + +public class TestDataUtil { + public static Comment createComment(Long id, Post post, User user, String content, Comment parentComment) { + Comment comment = Comment.builder() + .post(post) + .user(user) + .username(user.getName()) + .content(content) + .parent(parentComment) + .build(); + ReflectionTestUtils.setField(comment, "id", id); + return comment; + } + public static Post createPost(Long id,String title,String content, User user) { + Post post = Post.builder() + .title(title) + .content(content) + .user(user) + .build(); + ReflectionTestUtils.setField(post, "id", id); + return post; + } + public static User createUser(Long id, String name,String email) { + User user = User.builder() + .name(name) + .password("password") + .email(email) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d71eb7..6cc6c5f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.9.0", + "base-64": "^1.0.0", "bootstrap": "^5.3.6", "http-proxy-middleware": "^3.0.5", "react": "^19.1.0", @@ -4928,6 +4929,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 28de095..8b866fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.9.0", + "base-64": "^1.0.0", "bootstrap": "^5.3.6", "http-proxy-middleware": "^3.0.5", "react": "^19.1.0", diff --git a/frontend/src/pages/posts/CommentPage.jsx b/frontend/src/pages/posts/CommentPage.jsx new file mode 100644 index 0000000..bc24d75 --- /dev/null +++ b/frontend/src/pages/posts/CommentPage.jsx @@ -0,0 +1,253 @@ +import React, { useState } from 'react'; +import { Button, Form, Card } from 'react-bootstrap'; +import axios from 'axios'; + + +function CommentSection({ postId, comments, myUserId, refreshPost }) { + // 댓글 입력/수정 상태 + const [commentContent, setCommentContent] = useState(''); + const [replyContent, setReplyContent] = useState(''); + const [editingId, setEditingId] = useState(null); // 수정 중인 댓글 id + const [editingContent, setEditingContent] = useState(''); + const [replyParentId, setReplyParentId] = useState(null); // 대댓글 입력창 표시용 parent id + + // 댓글 등록 + const handleCommentSubmit = async (e) => { + e.preventDefault(); + if (!commentContent.trim()) return; + try { + const token = localStorage.getItem('jwt'); + await axios.post('/api/comments', { + postId, + content: commentContent, + parentId: null, + }, { headers: { Authorization: token } }); + setCommentContent(''); + refreshPost(); // 게시글 및 댓글 데이터 새로고침 + } catch (err) { + console.error('댓글 등록 실패:', err); + alert(`댓글 등록 실패: ${err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'}`); + } + }; + + // 대댓글 등록 + const handleReplySubmit = async (e, parentId) => { + e.preventDefault(); + if (!replyContent.trim()) return; + try { + const token = localStorage.getItem('jwt'); + await axios.post('/api/comments', { + postId, + content: replyContent, + parentId, + }, { headers: { Authorization: token } }); + setReplyContent(''); + setReplyParentId(null); + refreshPost(); + } catch (err) { + console.error('대댓글 등록 실패:', err); + alert(`댓글 등록 실패: ${err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'}`); + } + }; + + // 댓글 삭제 + const handleDelete = async (commentId) => { + if (!window.confirm('댓글을 삭제할까요?')) return; + try { + const token = localStorage.getItem('jwt'); + await axios.delete(`/api/comments/${commentId}`, { + headers: { Authorization: token }, + }); + refreshPost(); + } catch (err) { + console.error('댓글 삭제 실패:', err); + alert(`댓글 삭제 실패: ${err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'}`); + } + }; + + // 댓글 수정 + const handleEditSubmit = async (e, commentId) => { + e.preventDefault(); + if (!editingContent.trim()) return; + try { + const token = localStorage.getItem('jwt'); + await axios.patch(`/api/comments/${commentId}`, { + content: editingContent, + }, { headers: { Authorization: token } }); + setEditingId(null); + setEditingContent(''); + refreshPost(); + } catch (err) { + console.error('댓글 수정 실패:', err); + alert(`댓글 수정 실패: ${err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'}`); + } + }; + + // 트리형 댓글 렌더링 + const renderComments = (commentList, depth = 0) => + commentList.map((comment) => ( +
+ + +
+ + {comment.username}{" "} + + {new Date(comment.createdAt).toLocaleString("ko-KR")} + + + {comment.createdAt !== comment.updatedAt && comment.deletedAt === null && " (수정됨)"} + + + {/* 내 댓글만 수정/삭제 */} + {!comment.deletedAt && myUserId && myUserId === comment.userId && ( + + {editingId === comment.commentId ? null : ( + <> + + + + )} + + )} +
+ {/* 댓글 내용 or 수정폼 */} + {editingId === comment.commentId ? ( +
handleEditSubmit(e, comment.commentId)} + > + setEditingContent(e.target.value)} + rows={2} + className="mb-2" + /> +
+ + +
+ + ) : ( +
+ {comment.deletedAt ? '삭제된 댓글입니다.' : comment.content} +
+ )} + + {/* 대댓글 입력창 */} + {!comment.deletedAt && comment.parentId === null && ( + <> + {replyParentId === comment.commentId ? ( +
handleReplySubmit(e, comment.commentId)} + > + setReplyContent(e.target.value)} + rows={2} + className="mb-2" + placeholder="대댓글을 입력하세요" + /> +
+ + +
+ + ) : editingId !== comment.commentId ? ( + + ) : null} + + + )} +
+
+ {/* 대댓글 트리 재귀 */} + {comment.children && comment.children.length > 0 && + renderComments(comment.children, depth + 1)} +
+ )); + + return ( +
+ {/* 댓글 입력 폼 */} +
+ + setCommentContent(e.target.value)} + placeholder="댓글을 입력하세요" + /> + + +
+ {/* 댓글 목록 */} + {comments && comments.length > 0 ? ( + renderComments(comments) + ) : ( +
아직 댓글이 없습니다.
+ )} +
+ ); +}; + +export default CommentSection; diff --git a/frontend/src/pages/posts/PostDetailPage.jsx b/frontend/src/pages/posts/PostDetailPage.jsx index a031e80..e4a6c02 100644 --- a/frontend/src/pages/posts/PostDetailPage.jsx +++ b/frontend/src/pages/posts/PostDetailPage.jsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import axios from 'axios'; import { Container, Card, Spinner, Alert, Button } from 'react-bootstrap'; import base64 from 'base-64'; +import CommentSection from './CommentPage'; // 댓글 컴포넌트 임포트 const PostDetailPage = () => { const { id } = useParams(); @@ -75,6 +76,7 @@ const PostDetailPage = () => { // 내 userId와 게시글 userId 비교 const isOwner = post && myUserId && String(post.userId) === String(myUserId); + return (