diff --git a/.env.example b/.env.example index a41a8d4..f255ead 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ SNOWTHING_DB_USERNAME=snowuser SNOWTHING_DB_PASSWORD=replace-with-a-local-password SNOWTHING_DB_ROOT_PASSWORD=replace-with-a-different-root-password -# Optional credentials for CommentCreateTest's fixed snowthing_test MySQL schema. +# Required credentials for CommentCreateTest and CommentUpdateTest against the MySQL test schema. +# Export these process environment variables before running either test; Gradle does not load .env automatically: +SNOWTHING_TEST_DB_URL=jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul SNOWTHING_TEST_DB_USERNAME=snowuser SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password diff --git a/README.md b/README.md index 19ebdc9..2489ab9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ * **Build & Test**: Gradle, JUnit5, Mockito, MockMvc ### Database & Cache -* **RDBMS**: H2 (In-Memory Dev), MySQL 8.0 (Production) +* **RDBMS**: MySQL 8.0 (local, test, and production) * **In-Memory Cache**: Spring Session Redis (Scale-out Ready) --- diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java index 142cd66..ced7bab 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java @@ -41,8 +41,10 @@ public ResponseEntity createComment( public ResponseEntity getCommentsByPost( @PathVariable String publicId, @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "20") int size) { - PostCommentListResponse response = commentService.getCommentsByPost(publicId, cursor, size); + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + PostCommentListResponse response = + commentService.getCommentsByPost(publicId, cursor, size, userDetails); return ResponseEntity.ok(response); } @@ -50,8 +52,20 @@ public ResponseEntity getCommentsByPost( public ResponseEntity getCommentReplies( @PathVariable Long commentId, @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(commentService.getCommentReplies(commentId, cursor, size)); + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok( + commentService.getCommentReplies(commentId, cursor, size, userDetails)); + } + + @PutMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + CommentUpdateResponse response = + commentService.updateComment(commentId, request, userDetails); + return ResponseEntity.ok(response); } @DeleteMapping("/comments/{commentId}") diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java index 5354e9b..3470ed0 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.ikae.snowthing.domain.comment.entity.Comment; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.global.util.WriterDisplayFormatter; @@ -19,6 +20,9 @@ public record CommentResponse( long replyCount, List previewReplies, boolean hasMoreReplies, + @JsonIgnore String ownerPublicId, + boolean canEdit, + boolean requiresPassword, LocalDateTime createdAt) { private static final String ANONYMOUS_NAME = "ㅇㅇ"; @@ -44,12 +48,15 @@ public static CommentResponse from(Comment comment) { comment.getParent() == null ? null : comment.getParent().getId(), writer, comment.isAnonymous(), - WriterDisplayFormatter.maskIp(comment.getWriterIp()), + comment.isAnonymous() ? WriterDisplayFormatter.maskIp(comment.getWriterIp()) : null, comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(), comment.isDeleted(), 0, List.of(), false, + member == null ? null : member.getPublicId(), + false, + false, comment.getCreatedAt()); } @@ -66,6 +73,38 @@ public CommentResponse withPreviewReplies(List replies) { replyCount, replies, hasMoreReplies, + ownerPublicId, + canEdit, + requiresPassword, + createdAt); + } + + public CommentResponse withViewerPermissions(String viewerPublicId) { + boolean editable = + !isDeleted + && (ownerPublicId == null + ? isAnonymous + : ownerPublicId.equals(viewerPublicId)); + boolean passwordRequired = editable && ownerPublicId == null && isAnonymous; + List visibleReplies = + previewReplies.stream() + .map(reply -> reply.withViewerPermissions(viewerPublicId)) + .toList(); + return new CommentResponse( + commentId, + postId, + parentId, + writer, + isAnonymous, + writerIp, + content, + isDeleted, + replyCount, + visibleReplies, + hasMoreReplies, + ownerPublicId, + editable, + passwordRequired, createdAt); } @@ -83,6 +122,9 @@ public CommentResponse withReplyInfo( replyCount, replies, hasMoreReplies, + ownerPublicId, + canEdit, + requiresPassword, createdAt); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..cc9575b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,10 @@ +package com.ikae.snowthing.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용은 필수 입력값입니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content, + String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java new file mode 100644 index 0000000..3313352 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java @@ -0,0 +1,5 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.time.LocalDateTime; + +public record CommentUpdateResponse(Long commentId, String content, LocalDateTime updatedAt) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java index 3b47e87..eef531c 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java @@ -9,6 +9,8 @@ import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.post.entity.Post; import com.ikae.snowthing.global.common.BaseTimeEntity; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; import lombok.AccessLevel; import lombok.Builder; @@ -31,11 +33,17 @@ @SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") public class Comment extends BaseTimeEntity { + private static final int MAX_CONTENT_LENGTH = 1000; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "comment_id") private Long id; + @Version + @Column(nullable = false) + private long version; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) private Post post; @@ -78,7 +86,7 @@ public Comment( this.post = post; this.member = member; this.parent = parent; - this.content = content; + this.content = validateContent(content); this.writerIp = writerIp; this.isAnonymous = isAnonymous; this.anonymousPassword = anonymousPassword; @@ -98,8 +106,8 @@ public static Comment create( public Comment rootParent() { Comment current = this; - while (current.getParent() != null) { - current = current.getParent(); + while (current.parent != null) { + current = current.parent; } return current; } @@ -108,4 +116,15 @@ public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); } + + public void updateContent(String newContent) { + this.content = validateContent(newContent); + } + + private static String validateContent(String content) { + if (content == null || content.isBlank() || content.length() > MAX_CONTENT_LENGTH) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return content; + } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java index 5c795f5..08b2007 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepositoryImpl.java @@ -218,12 +218,15 @@ private CommentResponse mapResponse(ResultSet rs, int rowNum) throws SQLExceptio nullableLong(rs, "parent_id"), writer, anonymous, - WriterDisplayFormatter.maskIp(rs.getString("writer_ip")), + anonymous ? WriterDisplayFormatter.maskIp(rs.getString("writer_ip")) : null, deleted ? "삭제된 댓글입니다." : rs.getString("content"), deleted, rs.getLong("reply_count"), List.of(), rs.getBoolean("has_more_replies"), + memberPublicId, + false, + false, rs.getObject("created_at", LocalDateTime.class)); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentCommandService.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentCommandService.java new file mode 100644 index 0000000..4632e37 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentCommandService.java @@ -0,0 +1,82 @@ +package com.ikae.snowthing.domain.comment.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +class CommentCommandService { + private static final long MAX_REPLY_COUNT = 100L; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Transactional + CommentResponse createComment( + String postPublicId, + CommentCreateRequest request, + Member member, + String encodedPassword, + CustomUserDetails userDetails, + String clientIp) { + Post post = + postRepository + .findByPublicId(postPublicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + Comment parent = null; + if (request.parentId() != null) { + Comment requestedParent = + commentRepository + .findByIdForUpdate(request.parentId()) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode.PARENT_COMMENT_NOT_FOUND)); + if (!requestedParent.getPost().getId().equals(post.getId())) { + throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); + } + Long rootId = requestedParent.rootParent().getId(); + parent = + commentRepository + .findByIdForUpdate(rootId) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode.PARENT_COMMENT_NOT_FOUND)); + if (commentRepository.findActiveReplyIdsForUpdate(rootId).size() >= MAX_REPLY_COUNT) { + throw new CustomAuthException(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); + } + } + Comment comment = + Comment.create( + post, + member, + parent, + request.content(), + clientIp != null ? clientIp : "127.0.0.1", + request.isAnonymous(), + encodedPassword); + CommentResponse response = + CommentResponse.from(commentRepository.save(comment)) + .withViewerPermissions( + userDetails == null ? null : userDetails.getPublicId()); + postRepository.increaseCommentCount(post.getId()); + return response; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java index c3b197d..ee7d1ab 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java @@ -5,7 +5,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; import com.ikae.snowthing.domain.comment.dto.*; import com.ikae.snowthing.domain.comment.entity.Comment; @@ -26,7 +25,6 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class CommentService { private static final long MAX_REPLY_COUNT = 100L; @@ -37,9 +35,8 @@ public class CommentService { private final PostRepository postRepository; private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - private final TransactionTemplate transactionTemplate; + private final CommentCommandService commentCommandService; - @Transactional public CommentResponse createComment( String postPublicId, CommentCreateRequest request, @@ -50,14 +47,18 @@ public CommentResponse createComment( if (request.isAnonymous()) { if (userDetails != null) { - member = memberRepository.findByPublicId(userDetails.getPublicId()).orElse(null); - } - if (member == null - && (request.anonymousPassword() == null - || request.anonymousPassword().isBlank())) { - throw new CustomAuthException(ErrorCode.INVALID_INPUT); - } - if (request.anonymousPassword() != null && !request.anonymousPassword().isBlank()) { + member = + memberRepository + .findByPublicId(userDetails.getPublicId()) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + if (hasAnonymousPassword(request.anonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + } else { + if (!hasAnonymousPassword(request.anonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } encodedPassword = passwordEncoder.encode(request.anonymousPassword()); } } else { @@ -73,72 +74,23 @@ public CommentResponse createComment( final Member finalMember = member; final String finalEncodedPassword = encodedPassword; - return transactionTemplate.execute( - status -> { - Post post = - postRepository - .findByPublicId(postPublicId) - .orElseThrow( - () -> - new CustomAuthException( - ErrorCode.POST_NOT_FOUND)); - - validatePostVisibility(post); - - Comment parent = null; - if (request.parentId() != null) { - Comment requestedParent = - commentRepository - .findById(request.parentId()) - .orElseThrow( - () -> - new CustomAuthException( - ErrorCode - .PARENT_COMMENT_NOT_FOUND)); - - if (!requestedParent.getPost().getId().equals(post.getId())) { - throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); - } - - Long rootCommentId = requestedParent.rootParent().getId(); - parent = - commentRepository - .findByIdForUpdate(rootCommentId) - .orElseThrow( - () -> - new CustomAuthException( - ErrorCode - .PARENT_COMMENT_NOT_FOUND)); - - long activeReplyCount = - commentRepository.findActiveReplyIdsForUpdate(rootCommentId).size(); - if (activeReplyCount >= MAX_REPLY_COUNT) { - throw new CustomAuthException(ErrorCode.COMMENT_REPLY_LIMIT_EXCEEDED); - } - } - - Comment comment = - Comment.create( - post, - finalMember, - parent, - request.content(), - clientIp != null ? clientIp : "127.0.0.1", - request.isAnonymous(), - finalEncodedPassword); - - Comment savedComment = commentRepository.save(comment); - postRepository.increaseCommentCount(post.getId()); - - return CommentResponse.from(savedComment); - }); + return commentCommandService.createComment( + postPublicId, request, finalMember, finalEncodedPassword, userDetails, clientIp); } + @Transactional(readOnly = true) public PostCommentListResponse getCommentsByPost(String postPublicId) { return getCommentsByPost(postPublicId, null, DEFAULT_READ_SIZE); } + @Transactional(readOnly = true) public PostCommentListResponse getCommentsByPost(String postPublicId, Long cursor, int size) { + return getCommentsByPost(postPublicId, cursor, size, null); + } + + @Transactional(readOnly = true) + public PostCommentListResponse getCommentsByPost( + String postPublicId, Long cursor, int size, CustomUserDetails userDetails) { validateReadSize(size); Post post = postRepository @@ -169,13 +121,26 @@ public PostCommentListResponse getCommentsByPost(String postPublicId, Long curso return root.withReplyInfo( stat.totalCount(), stat.totalCount() > 5, rootPreviews); }) + .map( + comment -> + comment.withViewerPermissions( + userDetails == null + ? null + : userDetails.getPublicId())) .toList(); Long nextCursor = hasNext && !comments.isEmpty() ? comments.getLast().commentId() : null; return new PostCommentListResponse( postPublicId, post.getCommentCount(), comments, nextCursor, hasNext); } + @Transactional(readOnly = true) public CommentReplyListResponse getCommentReplies(Long commentId, Long cursor, int size) { + return getCommentReplies(commentId, cursor, size, null); + } + + @Transactional(readOnly = true) + public CommentReplyListResponse getCommentReplies( + Long commentId, Long cursor, int size, CustomUserDetails userDetails) { validateReadSize(size); Comment root = commentRepository @@ -196,7 +161,16 @@ public CommentReplyListResponse getCommentReplies(Long commentId, Long cursor, i } List fetched = commentRepository.findReplies(commentId, cursor, size + 1); boolean hasNext = fetched.size() > size; - List replies = List.copyOf(hasNext ? fetched.subList(0, size) : fetched); + List replies = + (hasNext ? fetched.subList(0, size) : fetched) + .stream() + .map( + reply -> + reply.withViewerPermissions( + userDetails == null + ? null + : userDetails.getPublicId())) + .toList(); Long nextCursor = hasNext && !replies.isEmpty() ? replies.getLast().commentId() : null; return new CommentReplyListResponse( commentId, commentRepository.countReplies(commentId), replies, nextCursor, hasNext); @@ -210,7 +184,48 @@ private void validatePostVisibility(Post post) { private void validateReadSize(int size) { if (size < 1 || size > MAX_READ_SIZE) { - throw new CustomAuthException(ErrorCode.INVALID_INPUT); + throw new CustomAuthException(ErrorCode.COMMENT_INVALID_PAGE_SIZE); + } + } + + @Transactional + public CommentUpdateResponse updateComment( + Long commentId, CommentUpdateRequest request, CustomUserDetails userDetails) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); + } + + validateUpdatePermission(comment, request.anonymousPassword(), userDetails); + + comment.updateContent(request.content()); + commentRepository.flush(); + + return new CommentUpdateResponse( + comment.getId(), comment.getContent(), comment.getUpdatedAt()); + } + + private void validateUpdatePermission( + Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + if (comment.getMember() != null) { + if (isWriter(comment, userDetails)) { + return; + } + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + if (!comment.isAnonymous()) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + if (!hasAnonymousPassword(anonymousPassword) + || comment.getAnonymousPassword() == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); } } @@ -242,31 +257,30 @@ private void validateDeletePermission( return; } - if (comment.isAnonymous()) { - if (userDetails != null - && comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { + if (comment.getMember() != null) { + if (isWriter(comment, userDetails)) { return; } - - if (anonymousPassword == null - || !passwordEncoder.matches( - anonymousPassword, comment.getAnonymousPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); - } - return; + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } - if (userDetails == null) { + if (!comment.isAnonymous()) { throw new CustomAuthException(ErrorCode.ACCESS_DENIED); } - boolean isWriter = - comment.getMember() != null - && comment.getMember().getPublicId().equals(userDetails.getPublicId()); - - if (!isWriter) { - throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + if (!hasAnonymousPassword(anonymousPassword) + || comment.getAnonymousPassword() == null + || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); } } + + private boolean isWriter(Comment comment, CustomUserDetails userDetails) { + return userDetails != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + } + + private boolean hasAnonymousPassword(String anonymousPassword) { + return anonymousPassword != null && !anonymousPassword.isBlank(); + } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 6571e7b..ea35367 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -22,7 +22,7 @@ import lombok.RequiredArgsConstructor; @Component -@Profile("!test") +@Profile("local") @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 1e78ef7..6ea254d 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -28,6 +28,10 @@ public enum ErrorCode { HttpStatus.BAD_REQUEST, "COMMENT_004", "루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."), INVALID_PAGE_SIZE(HttpStatus.BAD_REQUEST, "COMMON_002", "페이지 크기는 1 이상 100 이하이어야 합니다."), + COMMENT_INVALID_PAGE_SIZE( + HttpStatus.BAD_REQUEST, "COMMENT_005", "댓글 페이지 크기는 1 이상 50 이하이어야 합니다."), + COMMENT_UPDATE_CONFLICT( + HttpStatus.CONFLICT, "COMMENT_006", "다른 요청에서 댓글을 먼저 수정했습니다. 최신 댓글을 다시 확인해 주세요."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index 5c93c9d..e114580 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,8 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -33,6 +35,13 @@ public ResponseEntity handleDataIntegrityViolationException( .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, "요청 데이터의 고유 제약조건 또는 무결성을 위반했습니다.")); } + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockingFailure( + ObjectOptimisticLockingFailureException e) { + return ResponseEntity.status(ErrorCode.COMMENT_UPDATE_CONFLICT.getStatus()) + .body(ErrorResponse.from(ErrorCode.COMMENT_UPDATE_CONFLICT)); + } + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) public ResponseEntity handleAuthenticationException(Exception e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -59,6 +68,14 @@ public ResponseEntity handleValidationException( : ErrorCode.INVALID_INPUT.getMessage())); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.warn("요청 본문을 읽을 수 없습니다: {}", e.getMessage()); + return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus()) + .body(ErrorResponse.from(ErrorCode.INVALID_INPUT)); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception e) { log.error("서버 내부 미처리 예외 발생: ", e); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java index af90c1f..4040545 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/CommentReadTest.java @@ -58,6 +58,7 @@ class CommentReadTest { @Autowired private MockMvc mockMvc; private CustomUserDetails userDetails; + private CustomUserDetails otherUserDetails; private PostResponse post; @BeforeEach @@ -78,6 +79,15 @@ void setUp() { .role(Role.ROLE_USER) .build()); userDetails = new CustomUserDetails(member); + Member otherMember = + memberRepository.save( + Member.builder() + .email("comment-read-other@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글조회다른사용자") + .role(Role.ROLE_USER) + .build()); + otherUserDetails = new CustomUserDetails(otherMember); post = createPost("댓글 조회 게시글"); } @@ -160,7 +170,36 @@ void topFivePreviewAndSeparatedReplies() throws Exception { .param("size", "20")) .andExpect(status().isOk()) .andExpect(jsonPath("$.rootCommentId").value(root.commentId())) - .andExpect(jsonPath("$.replies.length()").value(2)); + .andExpect(jsonPath("$.replies.length()").value(2)) + .andExpect(jsonPath("$.replies[0].canEdit").value(false)) + .andExpect(jsonPath("$.replies[0].requiresPassword").value(false)) + .andExpect(jsonPath("$.replies[0].ownerPublicId").doesNotExist()); + } + + @Test + @DisplayName("삭제된 대댓글도 placeholder로 노출하며 대댓글 수와 더보기 기준을 일치시킨다") + void deletedRepliesKeepResponseCountsConsistent() { + CommentResponse root = createRoot("삭제 대댓글 집계 루트"); + List replies = + java.util.stream.IntStream.rangeClosed(1, 7) + .mapToObj(index -> createReply(root.commentId(), "대댓글 " + index)) + .toList(); + commentService.deleteComment(replies.get(0).commentId(), null, userDetails); + commentService.deleteComment(replies.get(1).commentId(), null, userDetails); + + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20); + CommentResponse rootResponse = response.comments().getFirst(); + + assertThat(rootResponse.replyCount()).isEqualTo(7); + assertThat(rootResponse.previewReplies()).hasSize(5); + assertThat(rootResponse.previewReplies().getFirst().isDeleted()).isTrue(); + assertThat(rootResponse.hasMoreReplies()).isTrue(); + + CommentReplyListResponse repliesResponse = + commentService.getCommentReplies(root.commentId(), null, 20); + assertThat(repliesResponse.totalReplyCount()).isEqualTo(7); + assertThat(repliesResponse.replies()).hasSize(7); } @Test @@ -222,6 +261,68 @@ void responseCollectionsAreImmutable() { assertThatThrownBy(() -> response.comments().getFirst().previewReplies().clear()) .isInstanceOf(UnsupportedOperationException.class); } + + @Test + @DisplayName("댓글 수정 UI 권한은 서버의 작성 주체별 권한 정책과 일치한다") + void editPermissionMetadataMatchesOwnershipPolicy() { + CommentResponse memberComment = createRoot("회원 댓글"); + CommentResponse memberAnonymousComment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "로그인 익명 댓글", true, null), + userDetails, + "127.0.0.1"); + CommentResponse guestAnonymousComment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "비회원 익명 댓글", true, "password1234"), + null, + "127.0.0.1"); + + PostCommentListResponse ownerView = + commentService.getCommentsByPost(post.publicId(), null, 20, userDetails); + assertThat(findComment(ownerView, memberComment.commentId()).canEdit()).isTrue(); + assertThat(findComment(ownerView, memberComment.commentId()).requiresPassword()) + .isFalse(); + assertThat(findComment(ownerView, memberAnonymousComment.commentId()).canEdit()) + .isTrue(); + assertThat( + findComment(ownerView, memberAnonymousComment.commentId()) + .requiresPassword()) + .isFalse(); + assertThat(findComment(ownerView, guestAnonymousComment.commentId()).canEdit()) + .isTrue(); + assertThat(findComment(ownerView, guestAnonymousComment.commentId()).requiresPassword()) + .isTrue(); + + PostCommentListResponse otherView = + commentService.getCommentsByPost(post.publicId(), null, 20, otherUserDetails); + assertThat(findComment(otherView, memberComment.commentId()).canEdit()).isFalse(); + assertThat(findComment(otherView, memberAnonymousComment.commentId()).canEdit()) + .isFalse(); + assertThat(findComment(otherView, guestAnonymousComment.commentId()).canEdit()) + .isTrue(); + } + + @Test + @DisplayName("대댓글 분리 조회에도 동일한 수정 UI 권한을 적용한다") + void separatedReplyPermissionMetadataMatchesOwnershipPolicy() { + CommentResponse root = createRoot("권한 확인 루트"); + CommentResponse memberAnonymousReply = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(root.commentId(), "로그인 익명 대댓글", true, null), + userDetails, + "127.0.0.1"); + + CommentReplyListResponse ownerView = + commentService.getCommentReplies(root.commentId(), null, 20, userDetails); + CommentReplyListResponse otherView = + commentService.getCommentReplies(root.commentId(), null, 20, otherUserDetails); + + assertThat(findReply(ownerView, memberAnonymousReply.commentId()).canEdit()).isTrue(); + assertThat(findReply(otherView, memberAnonymousReply.commentId()).canEdit()).isFalse(); + } } @Nested @@ -237,20 +338,22 @@ void postNotFound() { } @Test - @DisplayName("페이지 크기가 허용 범위를 벗어나면 INVALID_INPUT을 반환한다") + @DisplayName("댓글 페이지 크기가 허용 범위를 벗어나면 COMMENT_INVALID_PAGE_SIZE를 반환한다") void invalidPageSize() throws Exception { assertErrorCode( () -> commentService.getCommentsByPost(post.publicId(), null, 0), - ErrorCode.INVALID_INPUT); + ErrorCode.COMMENT_INVALID_PAGE_SIZE); assertErrorCode( () -> commentService.getCommentsByPost(post.publicId(), null, 51), - ErrorCode.INVALID_INPUT); + ErrorCode.COMMENT_INVALID_PAGE_SIZE); mockMvc.perform( get("/api/v1/posts/{publicId}/comments", post.publicId()) .param("size", "51")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())); + .andExpect( + jsonPath("$.code") + .value(ErrorCode.COMMENT_INVALID_PAGE_SIZE.getCode())); } @Test @@ -296,6 +399,36 @@ void cursorFromDifferentRoot() { ErrorCode.COMMENT_NOT_FOUND); } + @Test + @DisplayName("일반 회원 댓글은 writerIp가 null이고, 익명 댓글은 마스킹된 IP를 반환한다") + void getComments_masksWriterIpOnlyForAnonymous() { + CommentResponse memberComment = createRoot("회원 댓글"); + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .content("익명 댓글") + .isAnonymous(true) + .anonymousPassword("1234") + .build(), + null, + "211.234.10.20"); + + PostCommentListResponse response = + commentService.getCommentsByPost(post.publicId(), null, 20, null); + + CommentResponse foundMember = findComment(response, memberComment.commentId()); + assertThat(foundMember.isAnonymous()).isFalse(); + assertThat(foundMember.writerIp()).isNull(); + + CommentResponse foundAnon = + response.comments().stream() + .filter(CommentResponse::isAnonymous) + .findFirst() + .orElseThrow(); + assertThat(foundAnon.isAnonymous()).isTrue(); + assertThat(foundAnon.writerIp()).isEqualTo("211.234.***.***"); + } + @Test @DisplayName("게시글이 삭제된 경우 대댓글 직접 조회 시 POST_NOT_FOUND를 반환한다") void repliesOfDeletedPostThrowsException() { @@ -361,4 +494,18 @@ private void assertErrorCode(Runnable action, ErrorCode errorCode) { .extracting("errorCode") .isEqualTo(errorCode); } + + private CommentResponse findComment(PostCommentListResponse response, Long commentId) { + return response.comments().stream() + .filter(comment -> comment.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + } + + private CommentResponse findReply(CommentReplyListResponse response, Long commentId) { + return response.replies().stream() + .filter(comment -> comment.commentId().equals(commentId)) + .findFirst() + .orElseThrow(); + } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java index 2507d36..76fcbfe 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java @@ -1,5 +1,6 @@ package com.ikae.snowthing.domain.comment.controller; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; @@ -24,6 +25,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateRequest; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; import com.ikae.snowthing.domain.comment.service.CommentService; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.entity.Role; @@ -52,6 +56,8 @@ class CommentControllerTest { @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PasswordEncoder passwordEncoder; private Member member; @@ -186,4 +192,125 @@ void deleteComment_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").exists()); } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 작성자 인증과 CSRF 토큰으로 수정하면 200 OK") + void updateComment_success() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정 후 댓글", null); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentId").value(comment.commentId())) + .andExpect(jsonPath("$.content").value("수정 후 댓글")) + .andExpect(jsonPath("$.updatedAt").exists()); + + Comment updated = commentRepository.findById(comment.commentId()).orElseThrow(); + assertThat(updated.getContent()).isEqualTo("수정 후 댓글"); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 다른 회원이면 AUTH_002와 403을 반환한다") + void updateComment_forbiddenForOtherMember() throws Exception { + CommentResponse comment = createMemberComment("작성자 댓글"); + Member otherMember = + memberRepository.save( + Member.builder() + .email("comment-update-other@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글수정타인") + .role(Role.ROLE_USER) + .build()); + CustomUserDetails otherUserDetails = new CustomUserDetails(otherMember); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(otherUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest("타인의 수정", null)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_002")); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 공백 본문은 COMMON_001과 400을 반환한다") + void updateComment_rejectsInvalidRequestBody() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\" \",\"anonymousPassword\":null}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_001")); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - JSON 역직렬화 실패 시 400을 반환한다") + void updateComment_rejectsMalformedJson() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":{")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - CSRF 토큰이 없으면 403을 반환한다") + void updateComment_rejectsRequestWithoutCsrfToken() throws Exception { + CommentResponse comment = createMemberComment("수정 전 댓글"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest("수정 시도", null)))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("PUT /api/v1/comments/{commentId} - 비회원 익명 댓글은 비밀번호로 수정하면 200 OK") + void updateGuestAnonymousComment_success() throws Exception { + CommentResponse comment = + commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, "비회원 익명 댓글", true, "password1234"), + null, + "127.0.0.1"); + + mockMvc.perform( + put("/api/v1/comments/{commentId}", comment.commentId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + new CommentUpdateRequest( + "비회원 수정 댓글", "password1234")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("비회원 수정 댓글")); + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + post.publicId(), + new CommentCreateRequest(null, content, false, null), + userDetails, + "127.0.0.1"); + } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java index 3106180..ef9f43f 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/dto/CommentResponseTest.java @@ -7,9 +7,62 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.post.entity.Post; class CommentResponseTest { + @Test + @DisplayName("일반 회원 댓글은 클라이언트에 writerIp를 노출하지 않는다 (null)") + void from_memberComment_doesNotExposeWriterIp() { + Member member = + Member.builder() + .email("user@example.com") + .password("encodedPassword") + .nickname("보더스노우") + .role(Role.ROLE_USER) + .build(); + ReflectionTestUtils.setField(member, "publicId", "mbr_public_123"); + + Post post = Post.builder().title("게시글").content("내용").build(); + ReflectionTestUtils.setField(post, "id", 1L); + + Comment comment = + Comment.create(post, member, null, "일반 회원 댓글", "192.168.0.15", false, null); + ReflectionTestUtils.setField(comment, "id", 10L); + ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now()); + + CommentResponse response = CommentResponse.from(comment); + + assertThat(response.isAnonymous()).isFalse(); + assertThat(response.writerIp()).isNull(); + assertThat(response.writer()).isNotNull(); + assertThat(response.writer().nickname()).isEqualTo("보더스노우"); + assertThat(response.writerName()).isEqualTo("보더스노우"); + } + + @Test + @DisplayName("익명 댓글은 마스킹된 writerIp를 전달하고 writerName에 축약 IP를 포함한다") + void from_anonymousComment_exposesMaskedWriterIp() { + Post post = Post.builder().title("게시글").content("내용").build(); + ReflectionTestUtils.setField(post, "id", 1L); + + Comment comment = Comment.create(post, null, null, "익명 댓글", "211.234.120.45", true, "1234"); + ReflectionTestUtils.setField(comment, "id", 20L); + ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now()); + + CommentResponse response = CommentResponse.from(comment); + + assertThat(response.isAnonymous()).isTrue(); + assertThat(response.writerIp()).isEqualTo("211.234.***.***"); + assertThat(response.writer()).isNull(); + assertThat(response.writerName()).isEqualTo("ㅇㅇ(211.234)"); + } + @Test @DisplayName("[익명 댓글 IP 마스킹] 익명 댓글이고 IP가 주어지면 앞 두 자리만 포함하여 'ㅇㅇ(xxx.xxx)' 형태로 반환해야 한다") void writerName_AnonymousWithIp_ReturnsShortIp() { @@ -68,6 +121,9 @@ private CommentResponse createResponse( 0L, List.of(), false, + null, + false, + false, LocalDateTime.now()); } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java index fbbd3ab..7d37d81 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentCreateTest.java @@ -154,6 +154,22 @@ void createAnonymousCommentAsMember() { assertThat(savedComment.getAnonymousPassword()).isNull(); } + @Test + @DisplayName("로그인 회원은 익명 댓글 생성 시 비밀번호를 함께 보낼 수 없다") + void rejectAnonymousPasswordFromMember() { + assertThatThrownBy( + () -> + commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest( + null, "로그인 익명 댓글", true, "password1234"), + userDetails, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + @Test @DisplayName("비로그인 사용자는 비밀번호를 제공하면 익명 댓글을 생성할 수 있다") void createAnonymousCommentAsGuest() { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java new file mode 100644 index 0000000..0d18ad4 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java @@ -0,0 +1,423 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CommentUpdateTest { + + @DynamicPropertySource + static void useRealMySql(DynamicPropertyRegistry registry) { + String testDbUrl = System.getenv("SNOWTHING_TEST_DB_URL"); + if (testDbUrl == null || testDbUrl.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + registry.add("spring.datasource.url", () -> testDbUrl); + registry.add( + "spring.datasource.username", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_USERNAME")); + registry.add( + "spring.datasource.password", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_PASSWORD")); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add( + "spring.jpa.properties.hibernate.dialect", + () -> "org.hibernate.dialect.MySQLDialect"); + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return value; + } + + @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PostService postService; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + + private CustomUserDetails writerDetails; + private CustomUserDetails otherDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString().substring(0, 8); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member writer = + memberRepository.save( + new Member( + null, + "update-writer-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "수정작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + writerDetails = new CustomUserDetails(writer); + + Member other = + memberRepository.save( + new Member( + null, + "update-other-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "타인회원-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + otherDetails = new CustomUserDetails(other); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "수정 테스트 게시글", "게시글 본문", false, null, List.of()), + writerDetails, + "127.0.0.1"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCase { + + @Test + @DisplayName("[성공 1] 일반 회원 본인 댓글 수정 성공") + void updateOwnCommentAsMember() { + CommentResponse created = createMemberComment("수정 전 내용"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 후 내용", null), + writerDetails); + + assertThat(updated.commentId()).isEqualTo(created.commentId()); + assertThat(updated.content()).isEqualTo("수정 후 내용"); + assertThat(updated.updatedAt()).isNotNull(); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("수정 후 내용"); + } + + @Test + @DisplayName("[성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공") + void updateAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("익명 수정 전", "mypass1234"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("익명 수정 후", "mypass1234"), + null); + + assertThat(updated.content()).isEqualTo("익명 수정 후"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("익명 수정 후"); + } + + @Test + @DisplayName("로그인 익명 댓글은 작성자 세션으로 수정할 수 있다") + void updateMemberAnonymousCommentByOwnerSession() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("작성자 수정", null), + writerDetails); + + assertThat(updated.content()).isEqualTo("작성자 수정"); + } + + @Test + @DisplayName("비회원 익명 댓글은 올바른 비밀번호로 삭제할 수 있다") + void deleteGuestAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("비회원 익명 댓글", "password1234"); + + commentService.deleteComment(created.commentId(), "password1234", null); + + assertThat(commentRepository.findById(created.commentId()).orElseThrow().isDeleted()) + .isTrue(); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCase { + + @Test + @DisplayName("[실패 1] 로그인 회원이 타인의 댓글 수정 시도 시 ACCESS_DENIED (403)") + void rejectUpdateByOtherMember() { + CommentResponse created = createMemberComment("원본 댓글"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("타인이 수정", null), + otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("로그인 익명 댓글은 다른 사용자가 비밀번호를 보내도 수정할 수 없다") + void rejectPasswordFallbackForMemberAnonymousUpdate() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 시도", "password1234"), + otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("로그인 익명 댓글은 비회원이 비밀번호를 보내도 삭제할 수 없다") + void rejectPasswordFallbackForMemberAnonymousDelete() { + CommentResponse created = createMemberAnonymousComment("로그인 익명 댓글"); + + assertThatThrownBy( + () -> + commentService.deleteComment( + created.commentId(), "password1234", null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + + assertThat(commentRepository.findById(created.commentId()).orElseThrow().isDeleted()) + .isFalse(); + } + + @Test + @DisplayName("[실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithWrongPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "correct1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 시도", "wrong9999"), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + + @Test + @DisplayName("[실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnDeletedComment() { + CommentResponse created = createMemberComment("삭제할 댓글"); + commentService.deleteComment(created.commentId(), null, writerDetails); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("삭제 후 수정", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnNonExistentComment() { + assertThatThrownBy( + () -> + commentService.updateComment( + Long.MAX_VALUE, + new CommentUpdateRequest("수정 시도", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithNullPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "pass1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("비밀번호 없이 수정", null), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + } + + @Test + @DisplayName("수정 응답의 updatedAt은 수정 전 값보다 이후이다") + void returnUpdatedAtAfterFlush() { + CommentResponse created = createMemberComment("수정 전 본문"); + Comment beforeUpdate = commentRepository.findById(created.commentId()).orElseThrow(); + java.time.LocalDateTime previousUpdatedAt = beforeUpdate.getUpdatedAt(); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 후 본문", null), + writerDetails); + + assertThat(updated.updatedAt()).isAfter(previousUpdatedAt); + } + + @Nested + @DisplayName("엔티티 본문 불변식") + class EntityContentInvariant { + + @Test + @DisplayName("생성 시에도 잘못된 본문을 거부한다") + void rejectInvalidContentOnCreation() { + assertThatThrownBy( + () -> Comment.create(null, null, null, null, "127.0.0.1", true, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThatThrownBy( + () -> Comment.create(null, null, null, " ", "127.0.0.1", true, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThatThrownBy( + () -> + Comment.create( + null, + null, + null, + "a".repeat(1001), + "127.0.0.1", + true, + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("null 본문을 거부하고 기존 본문을 유지한다") + void rejectNullContent() { + assertInvalidEntityContent(null); + } + + @Test + @DisplayName("공백 본문을 거부하고 기존 본문을 유지한다") + void rejectBlankContent() { + assertInvalidEntityContent(" "); + } + + @Test + @DisplayName("1,000자를 초과한 본문을 거부하고 기존 본문을 유지한다") + void rejectOversizedContent() { + assertInvalidEntityContent("a".repeat(1001)); + } + } + + private void assertInvalidEntityContent(String invalidContent) { + CommentResponse created = createMemberComment("기존 본문"); + Comment comment = commentRepository.findById(created.commentId()).orElseThrow(); + + assertThatThrownBy(() -> comment.updateContent(invalidContent)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + assertThat(comment.getContent()).isEqualTo("기존 본문"); + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, false, null), + writerDetails, + "127.0.0.1"); + } + + private CommentResponse createGuestAnonymousComment(String content, String password) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, password), + null, + "127.0.0.1"); + } + + private CommentResponse createMemberAnonymousComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, null), + writerDetails, + "127.0.0.1"); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index b23cec2..d5ba857 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -6,10 +6,10 @@ spring: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration datasource: - url: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + url: ${SNOWTHING_TEST_DB_URL:jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul} driver-class-name: com.mysql.cj.jdbc.Driver - username: ${SNOWTHING_DB_USERNAME:snowuser} - password: ${SNOWTHING_DB_PASSWORD:snowthing_pass_2026!} + username: ${SNOWTHING_TEST_DB_USERNAME:${SNOWTHING_DB_USERNAME:snowuser}} + password: ${SNOWTHING_TEST_DB_PASSWORD:${SNOWTHING_DB_PASSWORD:snowthing_pass_2026!}} jpa: hibernate: diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 27938e4..5caa041 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -5,10 +5,10 @@ spring: - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration datasource: - url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - driver-class-name: org.h2.Driver - username: sa - password: + url: ${SNOWTHING_TEST_DB_URL} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${SNOWTHING_TEST_DB_USERNAME} + password: ${SNOWTHING_TEST_DB_PASSWORD} jpa: hibernate: diff --git a/database/spike_seed_comments.sql b/database/spike_seed_comments.sql index 4cd6566..c9fcdf3 100644 --- a/database/spike_seed_comments.sql +++ b/database/spike_seed_comments.sql @@ -5,24 +5,33 @@ USE `snowthing`; -- 0. 기존 Spike 데이터 초기화 -DELETE FROM `comment` WHERE `post_id` IN (998, 999); -DELETE FROM `post` WHERE `post_id` IN (998, 999); +DELETE c FROM `comment` c JOIN `post` p ON p.`post_id` = c.`post_id` +WHERE p.`public_id` IN ('post-spike-distributed-998', 'post-spike-hotspot-999'); +DELETE FROM `post` WHERE `public_id` IN ('post-spike-distributed-998', 'post-spike-hotspot-999'); --- 1. 테스트용 기본 카테고리 및 회원 확인/생성 -INSERT INTO `post_category` (`category_id`, `name`, `code`) VALUES (1, '자유게시판', 'FREE') -ON DUPLICATE KEY UPDATE `name` = '자유게시판'; -INSERT INTO `member` (`member_id`, `public_id`, `email`, `password`, `nickname`, `role`, `status`, `created_at`, `updated_at`) -VALUES (1, 'member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()) -ON DUPLICATE KEY UPDATE `nickname` = '스파이크테스터'; +-- 1. 테스트용 기본 카테고리 및 회원 확인/생성 (기존 1번 레코드 덮어쓰기 방지: 자연키 기반 안전 시딩) +INSERT INTO `post_category` (`name`, `code`) VALUES ('자유게시판', 'FREE') +ON DUPLICATE KEY UPDATE `category_id` = `category_id`; + +INSERT INTO `member` (`public_id`, `email`, `password`, `nickname`, `role`, `status`, `created_at`, `updated_at`) +VALUES ('member-spike-001', 'spike@snowthing.com', '$2a$10$dummyHashValueForSpikeTestingOnly1234567890', '스파이크테스터', 'ROLE_USER', 'ACTIVE', NOW(), NOW()) +ON DUPLICATE KEY UPDATE + `public_id` = IF(`public_id` = VALUES(`public_id`), `public_id`, NULL); + +-- 스파이크 전용 레코드의 실제 PK 식별자 조회 +SET @spike_category_id = (SELECT `category_id` FROM `post_category` WHERE `code` = 'FREE' LIMIT 1); +SET @spike_member_id = (SELECT `member_id` FROM `member` WHERE `public_id` = 'member-spike-001' LIMIT 1); -- 2. 테스트용 게시글 2개 생성 -- Post 998: 시나리오 A (분산 1,000건용) -INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) -VALUES (998, 'post-spike-distributed-998', 1, 1, 'Spike [시나리오 A] 분산 1,000건 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +INSERT INTO `post` (`public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) +VALUES ('post-spike-distributed-998', @spike_member_id, @spike_category_id, 'Spike [시나리오 A] 분산 1,000건 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +SET @spike_distributed_post_id = LAST_INSERT_ID(); -- Post 999: 시나리오 B (집중 핫스팟 1,000건용) -INSERT INTO `post` (`post_id`, `public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) -VALUES (999, 'post-spike-hotspot-999', 1, 1, 'Spike [시나리오 B] 핫스팟 500건 집중 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +INSERT INTO `post` (`public_id`, `member_id`, `category_id`, `title`, `content`, `writer_ip`, `is_anonymous`, `comment_count`, `created_at`, `updated_at`) +VALUES ('post-spike-hotspot-999', @spike_member_id, @spike_category_id, 'Spike [시나리오 B] 핫스팟 500건 집중 테스트 글', '내용', '127.0.0.1', FALSE, 1000, NOW(), NOW()); +SET @spike_hotspot_post_id = LAST_INSERT_ID(); -- ============================================================================== -- [시나리오 A] Post 998 : 루트 댓글 100개 + 각 루트당 대댓글 9개 = 총 1,000개 @@ -38,7 +47,7 @@ BEGIN -- 1. 루트 댓글 100개 생성 WHILE root_idx <= 100 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (998, 1, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + VALUES (@spike_distributed_post_id, @spike_member_id, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); SET current_root_id = LAST_INSERT_ID(); @@ -46,7 +55,7 @@ BEGIN SET reply_idx = 1; WHILE reply_idx <= 9 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (998, 1, current_root_id, CONCAT('대댓글 #', reply_idx, ' (부모:', current_root_id, ')'), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (root_idx * 10 + reply_idx) SECOND, NOW()); + VALUES (@spike_distributed_post_id, @spike_member_id, current_root_id, CONCAT('대댓글 #', reply_idx, ' (부모:', current_root_id, ')'), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (root_idx * 10 + reply_idx) SECOND, NOW()); SET reply_idx = reply_idx + 1; END WHILE; @@ -73,7 +82,7 @@ BEGIN -- 1. 루트 댓글 500개 생성 WHILE root_idx <= 500 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (999, 1, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); + VALUES (@spike_hotspot_post_id, @spike_member_id, NULL, CONCAT('루트 댓글 #', root_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL root_idx SECOND, NOW()); IF root_idx = 1 THEN SET hotspot_root_id = LAST_INSERT_ID(); @@ -85,7 +94,7 @@ BEGIN -- 2. 1번 루트 댓글에 대댓글 500개 집중 생성 WHILE reply_idx <= 500 DO INSERT INTO `comment` (`post_id`, `member_id`, `parent_id`, `content`, `writer_ip`, `is_anonymous`, `is_deleted`, `created_at`, `updated_at`) - VALUES (999, 1, hotspot_root_id, CONCAT('핫스팟 대댓글 #', reply_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (500 + reply_idx) SECOND, NOW()); + VALUES (@spike_hotspot_post_id, @spike_member_id, hotspot_root_id, CONCAT('핫스팟 대댓글 #', reply_idx), '127.0.0.1', FALSE, FALSE, NOW() + INTERVAL (500 + reply_idx) SECOND, NOW()); SET reply_idx = reply_idx + 1; END WHILE; END$$ @@ -99,5 +108,5 @@ SELECT `post_id`, COUNT(*) AS total_comments, SUM(CASE WHEN `parent_id` IS NULL THEN 1 ELSE 0 END) AS root_count, SUM(CASE WHEN `parent_id` IS NOT NULL THEN 1 ELSE 0 END) AS reply_count FROM `comment` -WHERE `post_id` IN (998, 999) +WHERE `post_id` IN (@spike_distributed_post_id, @spike_hotspot_post_id) GROUP BY `post_id`; diff --git a/docs/conception/sprint03/comment_api_spec.md b/docs/conception/sprint03/comment_api_spec.md index 41dbb80..96576cd 100644 --- a/docs/conception/sprint03/comment_api_spec.md +++ b/docs/conception/sprint03/comment_api_spec.md @@ -116,6 +116,8 @@ X-XSRF-TOKEN: {csrf_token} "writerIp": "211.234.***.***", "content": "하이원 아테나 슬로프 오픈했나요?", "isDeleted": false, + "canEdit": true, + "requiresPassword": false, "replyCount": 8, "previewReplies": [ { @@ -130,6 +132,8 @@ X-XSRF-TOKEN: {csrf_token} "writerIp": "175.120.***.***", "content": "네 오늘 오전 9시에 오픈했습니다!", "isDeleted": false, + "canEdit": false, + "requiresPassword": false, "createdAt": "2026-09-01T15:32:00" } ], @@ -144,6 +148,8 @@ X-XSRF-TOKEN: {csrf_token} "writerIp": "121.160.***.***", "content": "삭제된 댓글입니다.", "isDeleted": true, + "canEdit": false, + "requiresPassword": false, "replyCount": 1, "previewReplies": [ { @@ -158,6 +164,8 @@ X-XSRF-TOKEN: {csrf_token} "writerIp": "220.70.***.***", "content": "삭제된 질문이지만 답변 남깁니다. 야간개장은 18시부터입니다.", "isDeleted": false, + "canEdit": false, + "requiresPassword": false, "createdAt": "2026-09-01T15:35:00" } ], @@ -170,6 +178,9 @@ X-XSRF-TOKEN: {csrf_token} } ``` +- `canEdit`: 현재 요청 사용자가 해당 댓글을 수정할 수 있는지 나타냅니다. 로그인 익명 댓글도 작성자 세션이 일치할 때만 `true`입니다. +- `requiresPassword`: 수정 시 익명 비밀번호가 필요한지 나타냅니다. 비회원 익명 댓글에만 `true`이며, 작성 회원의 식별자는 익명 응답에 노출하지 않습니다. + --- ### 3. 대댓글 목록 분리 페이징 조회 (Read Separated Replies) @@ -204,6 +215,8 @@ X-XSRF-TOKEN: {csrf_token} "writerIp": "112.180.***.***", "content": "빅토리아 슬로프는 다음 주 오픈 예정이랍니다.", "isDeleted": false, + "canEdit": false, + "requiresPassword": false, "createdAt": "2026-09-01T15:40:00" } ], @@ -270,6 +283,7 @@ X-XSRF-TOKEN: {csrf_token} | HTTP Status | ErrorCode | 에러 메시지 | | :--- | :--- | :--- | | `400 Bad Request` | `COMMENT_004` | 루트 댓글 1개당 작성 가능한 대댓글 수는 최대 100개입니다. | +| `400 Bad Request` | `COMMENT_005` | 댓글 페이지 크기는 1 이상 50 이하이어야 합니다. | | `400 Bad Request` | `COMMENT_003` | 동일한 게시글의 댓글에만 대댓글을 달 수 있습니다. | | `400 Bad Request` | `COMMON_001` | 잘못된 입력값입니다. (글자수 제한 위반, 비밀번호 누락 등) | | `403 Forbidden` | `AUTH_002` | 해당 작업을 수행할 권한이 없습니다. | diff --git a/docs/project/work.md b/docs/project/work.md index 5e1410a..002a71e 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,16 @@ +- **Sprint 03 다중 PR 통합: PR #14 베이스 병합 및 PR #15 역병합 충돌 해결 (2026-09-06)**: + 1. **PR #14 (`feature/sprint03-comment-cr`) 머지 완결**: + - 베이스 브랜치(`feature/sprint03-comment`)로 PR #14 병합 완료 (`MERGED`). + 2. **PR #15 (`feature/sprint03-comment-u`) 역병합 및 15개 파일 충돌 해결**: + - CI/CD 워크플로 및 로컬/테스트 MySQL 8.0 단일화 설정 통합. + - `database/ddl.sql` 및 `Comment.java` 낙관적 락(`@Version`) + 최적화 인덱스 통합. + - `Comment.java` `rootParent()` 최상위 조상 탐색 루프와 `updateContent()` 통합. + - `CommentRepositoryCustom` 및 `CommentRepositoryImpl` 대댓글 카운트 및 익명 IP 마스킹 통합. + - `CommentResponse` 15개 필드 생성자, `withReplyInfo`, `withViewerPermissions`, 축약 IP 포맷팅 통합. + - `CommentService` `CommentCommandService` 트랜잭션 분리 및 게시글 가시성 검증 통합. + - 프론트엔드(`page.tsx`) 멘션 UI 제거 및 인라인 수정 폼 동시성 통합, `npm run build` 100% 성공. + - `CommentResponseTest` 단위 테스트 통합 (회원 IP 은닉 + 익명 축약 IP). + - **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: 프론트엔드 멘션 UI 제거 및 2-Depth 평탄화 정책 일치화 (2026-09-04)**: 1. **가짜 UI(Phantom UI) 제거 및 도메인 스펙 일치화**: - 프론트엔드에서 대댓글 작성 시 대상 닉네임(`@{replyMentionName} 님에게 답글`)을 노출했으나 백엔드 엔티티 및 스키마에는 루트 ID(`parentId`)와 본문만 저장되어 영속되지 않던 UI/데이터 불일치 결함 해소. @@ -81,7 +94,6 @@ - `PostRepositoryCustomTest` 카테고리 중복 가드 및 `CommentCreateTest` 동시성 테스트 DB 클린업 보강. - 실제 MySQL 8.0 환경 기반 **백엔드 전체 122개 단위/통합 테스트(`gradle test --rerun`) 100% BUILD SUCCESSFUL (32s)** 완전 통과. - - **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: PK(`comment_id`) 기반 논리적 시퀀스 단일 커서 전환 및 인덱스 최적화 (2026-09-04)**: 1. **시계열 오차 해소 및 쿼리 단순화**: - 기존 `(created_at, comment_id)` 복합 시계열 커서의 클락 스큐(Clock Skew) 및 트랜잭션 지연에 따른 누락(Phantom Skip) 위험을 해소하기 위해, 단조 증가하는 `comment_id` 단일 커서(`AND c.comment_id > :cursorId`) 및 단일 정렬(`ORDER BY c.comment_id ASC`)로 전환. @@ -92,14 +104,13 @@ 4. **검증 결과**: - Spotless 서식 교정(`spotlessApply`) 및 댓글 도메인 전체 단위/통합 테스트(`gradle test --tests com.ikae.snowthing.domain.comment.*`) **100% BUILD SUCCESSFUL (20s)** 통과. -- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: `CommentResponse` 작성자명 상수화 및 축약 IP 표기 적용 (2026-09-04)**: +- **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영: `CommentResponse` 작성자명 상수화 및 축약 IP 포맷팅 적용 (2026-09-04)**: 1. **작성자명 1줄 상수화 및 Plain String 제거**: - `CommentResponse.java` 내부에 `private static final String ANONYMOUS_NAME = "ㅇㅇ";` 상수를 선언하여 하드코딩된 리터럴 완전 제거 및 리뷰어 피드백 수용. 2. **익명 축약 IP 포맷팅 (`ㅇㅇ(xxx.xxx)`) 및 일반 회원 정보 보호**: - - 익명 댓글인 경우 4옥텟 전체 또는 긴 마스킹 문자열 대신 앞 2개 옥텟만 취하여 `ㅇㅇ(xxx.xxx)` 형태로 간결하게 노출. IP 누락 시 `ㅇㅇ` 반환. - - 비익명 일반 회원 댓글의 경우 IP 노출을 원천 차단하고 닉네임을 반환하며, 회원 객체 누락/탈퇴 시에도 `"알 수 없음"` 대신 기본 상수(`ㅇㅇ`)를 반환하도록 Early Return 패턴으로 로직 평탄화. - 3. **단위 테스트 검증**: - - `CommentResponseTest.java` 신설하여 5대 시나리오(마스킹 IP, 원시 IP, IP 누락, 회원 정상 닉네임, 회원 null fallback) 100% 검증 통과. + - 비회원 익명 댓글에 대해 4옥텟 IP 중 앞 두 자리만 노출하는 축약 IP 포맷팅(`ㅇㅇ(xxx.xxx)`) 적용. + - 일반 회원의 경우 IP를 완전히 은닉(`null`)하고 닉네임을 노출하도록 보장. + 3. **검증 결과**: - Spotless 서식 교정(`spotlessApply`) 및 댓글 도메인 전체 테스트(`gradle test --tests com.ikae.snowthing.domain.comment.*`) **100% BUILD SUCCESSFUL (16s)** 통과. - **Sprint 03 댓글 PR #14 코드리뷰 피드백 반영 및 대댓글 인덱스/설정 최적화 (2026-09-02)**: @@ -114,6 +125,47 @@ 4. **검증 결과**: - `spotlessCheck` 및 백엔드 전체 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL (23s)** 통과. +- **Sprint 03 PR #15 코드리뷰 피드백 반영 및 시드/설정/인덱스 안전화 완결 (2026-09-02)**: + 1. **스파이크 시드(`database/spike_seed_comments.sql`) 소유권 기반 안전 시딩 적용**: + - `post_category`, `member`의 고정 PK(1) 강제 삽입을 제거하고 자연키(`code = 'FREE'`, `public_id = 'member-spike-001'`) 기반 생성 및 변수(`@spike_member_id`) 바인딩으로 변경하여 기존 로컬 1번 회원 데이터 덮어쓰기 방지. + 2. **DB Username 환경변수 동기화 (Configuration Parity)**: + - `backend/src/main/resources/application.yml`의 `datasource.username`을 `docker-compose.yml`과 일치하도록 `${SNOWTHING_DB_USERNAME:snowuser}`로 수정. + 3. **.env.example 테스트 환경변수 가이드 보강**: + - `CommentCreateTest` 및 `CommentUpdateTest` 두 테스트 모두 실제 MySQL 연동을 지원함을 명시하고 `SNOWTHING_TEST_DB_URL` 표준 예시값 추가. + 4. **인덱스 및 테스트/초기화 무결성 동기화**: + - `ddl.sql` 및 `Comment.java` 대댓글 복합 인덱스(`idx_comment_parent_deleted_created`) 동기화. + - `DataInitializer.java` 닉네임 유니크 제약조건 중복 가드 추가. + - `CommentServiceTest.java` 플레이스홀더 도메인 규칙 및 테스트 간 DB 격리 클린업(`@AfterEach`) 보강. + 5. **검증 결과**: + - `spotlessApply` 서식 교정 완료. + - MySQL 스파이크 시드 스크립트 실행 실측 성공 (Post 998: 1,000건, Post 999: 1,000건 생성 확인). + - 백엔드 전체 단위/통합 테스트(`gradle test`) **125개 전수 통과 (BUILD SUCCESSFUL in 27s)**. + +- **Sprint 03 댓글 수정(PUT /api/v1/comments/{commentId}) 기능 및 테스트 전담 개발 완결 (2026-09-01)**: + 1. **작업명**: 댓글 수정(Update) 기능 구현 및 권한/유효성 검증 테스트 + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - `CommentUpdateRequest.java` DTO 신설 (`content` @NotBlank/@Size(max=1000), `anonymousPassword` 선택). + - `CommentUpdateResponse.java` DTO 신설 (`commentId`, `content`, `updatedAt`). + - `Comment.java` 엔티티 내 본문 갱신용 `updateContent(String newContent)` 더티 체킹 메서드 추가. + - `CommentService.java` 내 `updateComment` 및 `validateUpdatePermission` 구현 (수정 권한은 오직 작성자 본인만 가능하도록 관리자 우회 제외). + - `CommentController.java` 내 `PUT /api/v1/comments/{commentId}` 엔드포인트 연동. + - `CommentUpdateTest.java` 단위/통합 테스트 7건 작성 (성공 2건 + 실패 5건). + 4. **남은 항목**: 없음 (Update 전담 완료) + 5. **발견된 이슈 및 사용자 결정**: + - 이슈: 삭제(DELETE)와 달리 수정(PUT) 작업 시 관리자(`ROLE_ADMIN`)의 타인 댓글 본문 수정 허용 여부 정책 확인 필요. + - 사용자 결정: 수정은 오직 작성자 본인만 가능하도록 확정 (`validateUpdatePermission`에 관리자 우회 로직 배제). + 6. **검증 결과**: + - `spotlessApply` 서식 포맷팅 완료. + - `gradle test --tests "*CommentUpdateTest*"` 총 7개 테스트 케이스 100% PASS (BUILD SUCCESSFUL in 18s). + - [성공 1] 일반 회원 본인 댓글 수정 성공 + - [성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공 + - [실패 1] 로그인 회원이 타인 댓글 수정 시도 시 ACCESS_DENIED (403) + - [실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403) + - [실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403) + - **Sprint 03 댓글 도메인 공식 API 명세서(comment_api_spec.md) 작성 (2026-09-01)**: 1. **5대 CRUD 엔드포인트 계약 명세화**: `docs/conception/sprint03/comment_api_spec.md`에 댓글 작성(`POST`), 루트 댓글 Batch+Top-5 프리뷰 조회(`GET`), 대댓글 분리 페이징 조회(`GET`), 댓글 수정(`PUT`), Soft Delete 삭제(`DELETE`)의 Request/Response DTO, Header, 에러 코드 매핑을 100% 명세화. @@ -843,16 +895,104 @@ 6. 댓글·대댓글 응답 병합 시 `commentId` 중복을 방어하고, 삭제된 루트 placeholder 아래의 대댓글과 답글 작성 기능은 유지. 7. 검증 결과: 변경 파일 대상 ESLint 오류 0건(기존 `` 최적화 경고 1건), `npm run build` 및 TypeScript 검사 통과. 8. 확인 이슈: 전체 `npm run lint`는 이번 변경과 무관한 기존 `ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지의 오류 6건 때문에 실패. 브라우저 수동 검증은 백엔드와 테스트 데이터가 실행된 환경에서 추가 확인 필요. -- **Sprint 03 comment CR review fix: preview reply limit state synchronization (2026-09-04)**: - - After creating a reply, `previewReplies` is capped at five items with `slice(0, 5)`. - - `hasMoreReplies` is recalculated from the updated `replyCount`, so the load-more state is enabled immediately when the sixth reply is created. - - `npx eslint 'app/posts/[publicId]/page.tsx'` passed with no errors; one pre-existing `@next/next/no-img-element` warning remains. - -- **Sprint 03 comment CR review fix: prevent duplicate reply submissions (2026-09-04)**: - 1. Reused the existing `submittingComment` request state for reply submissions instead of introducing a separate state store or changing the API contract. - 2. Added an early-return guard to `handleCreateComment()` and disabled the reply submit button while a comment request is in progress. - 3. No backend API, database schema, or external dependency changes were required. - 4. Validation: targeted ESLint passed with no errors and one pre-existing `@next/next/no-img-element` warning; `npm run build` passed. +## Sprint 03 댓글 수정 프론트엔드 UI + +- 상태: DONE +- 시작일: 2026-09-01 + +### 계획 +- 루트 댓글과 대댓글에 한 번에 하나만 열리는 인라인 수정 폼을 적용한다. +- 일반 회원 댓글은 `writer.publicId`가 현재 사용자와 같을 때 수정 버튼을 노출한다. +- 익명 댓글은 소유권 응답 필드가 없어 버튼 노출 후 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 삭제 UI 변경은 `feature/sprint03-comment-d`로 분리하고 이 브랜치에는 포함하지 않는다. + +### 완료 +- 공백, 1,000자 제한, 변경 없음, 비로그인 익명 비밀번호를 검증하는 인라인 수정 폼 구현. +- PUT 성공 시 루트 또는 대댓글의 해당 `commentId` 본문만 불변 업데이트하도록 구현. +- 답글 작성 폼과 수정 폼이 동시에 열리지 않도록 수정 시작 시 답글 상태 초기화. + +### 남은 작업 +- 백엔드 Update API와 실제 브라우저 통합 검증. + +### 이슈 +- 익명 댓글 응답에 `canEdit`, `requiresPassword`가 없어 프론트만으로 정확한 소유권 버튼 노출은 불가능하다. + +### 결정 필요 +- 방안 A 적용을 사용자 승인받았으며 서버를 최종 권한 검증 주체로 사용한다. + +### 검증 +- 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. +- `npm run build` 성공 및 TypeScript 오류 0건 확인. +- **Sprint 03 테스트 환경 MySQL 단일화 (2026-09-06)**: + - H2 의존성·datasource·dialect를 제거하고 모든 Spring Boot 테스트 설정을 MySQL 8.0/InnoDB로 통일했습니다. + - `CommentCreateTest`와 `CommentUpdateTest`는 `SNOWTHING_TEST_DB_URL` 누락 시 fallback 없이 즉시 실패하며, `.env.example`에 프로세스 환경변수 전달 방법을 명시했습니다. + +- **Sprint 03 댓글 수정 감사 시각 및 본문 불변식 보강 (2026-09-06)**: + - 상태: DONE + - `CommentService.updateComment()`가 본문 변경 후 repository를 flush한 다음 응답을 생성하도록 변경하여 `@LastModifiedDate`가 갱신된 `updatedAt`을 반환하게 했습니다. + - `Comment` 생성자와 `updateContent()`가 공통 본문 검증을 사용하도록 변경하여 null, 공백, 1,000자 초과 값을 `INVALID_INPUT`으로 즉시 거부합니다. + - `CommentUpdateTest`에 수정 전보다 이후인 응답 `updatedAt`, 생성·수정 엔티티 불변식, 실패 후 기존 본문 보존 검증을 추가했습니다. + - 검증: `compileTestJava` 통과. `spotlessCheck`는 기존 수정 파일 `CommentReadTest.java`의 혼합 줄바꿈 위반 때문에 전체 완료되지 않았으며, 이번 변경 파일의 포맷 지적은 해소했습니다. + - 테스트 실행: 로컬 `snowthing-mysql` 컨테이너에 `snowthing_test` 스키마를 준비하고 자격정보를 해당 Gradle 프로세스에만 주입하여 `./gradlew.bat test --tests "*CommentUpdateTest*"`를 실행했습니다. 총 12건 모두 통과했습니다. +- **Sprint 03 댓글 페이지 크기 전용 오류 코드 추가 (2026-09-06)**: + - 댓글 조회의 잘못된 `size` 요청에 `COMMENT_005`를 사용하도록 변경했습니다. + - 게시글 API의 공용 `INVALID_PAGE_SIZE(COMMON_002)` 계약은 변경하지 않았습니다. + - `CommentReadTest`에 서비스·HTTP 응답 오류 코드 검증을 반영했습니다. +- **DataInitializer 운영 실행 방지 (2026-09-06)**: + - 샘플 회원·고정 관리자·마스터 데이터 초기화기를 `@Profile("local")`로 제한했습니다. + - `docker`, `prod`, `test` 프로필에서는 초기화기가 로드되지 않아 운영 환경에서 고정 관리자 계정이 자동 생성되지 않습니다. + - 운영 관리자 계정은 별도 운영 생성·시크릿 주입 절차로 관리해야 합니다. + +- **Sprint 03 로그인 익명 댓글 소유권 정책 보강 (2026-09-06)**: + - 로그인 사용자가 익명 댓글을 생성할 때 `anonymousPassword`를 함께 보내면 `INVALID_INPUT`으로 거부하고, 회원 식별자가 없는 비회원 익명 댓글에만 비밀번호 해시를 저장하도록 변경했습니다. + - 수정·삭제 권한 판단을 `isAnonymous` 단독 기준에서 `member_id` 존재 여부 기준으로 변경했습니다. 회원이 작성한 익명 댓글은 작성자 세션으로만 수정·삭제할 수 있고, 비회원 익명 댓글만 비밀번호 검증 경로를 사용합니다. 관리자의 삭제 권한은 기존 정책대로 유지했습니다. + - 성공 테스트로 로그인 익명 작성자의 세션 수정과 비회원 익명 댓글의 비밀번호 삭제를 검증하고, 실패 테스트로 로그인 사용자의 비밀번호 동시 제출 거부 및 타 사용자·비회원의 비밀번호 우회 수정·삭제 차단을 검증했습니다. + - 검증 결과: `spotlessCheck` 통과, 로컬 MySQL 8.0의 `snowthing_test` 스키마에서 `CommentCreateTest`와 `CommentUpdateTest` 총 33건 통과했습니다. + - 확인 이슈: 테스트 종료 시 Hibernate `create-drop` 정리 과정에서 외래 키 제거 실패 로그가 출력되지만 Gradle 테스트 결과는 성공입니다. 테스트 컨텍스트가 둘 이상 생성되며 동일 스키마 정리를 시도하는 기존 테스트 환경 문제로, 이번 권한 정책 변경의 실패는 아닙니다. + +- **Sprint 03 댓글 수정 버튼 권한 응답 정합성 보강 (2026-09-06)**: + - 댓글 조회 응답에 현재 요청자 기준 `canEdit`, `requiresPassword`를 추가했습니다. 익명 댓글의 실제 회원 식별자는 `ownerPublicId` 내부 필드로만 판정하고 `@JsonIgnore`로 응답에서 제외했습니다. + - 공개 조회 컨트롤러가 선택적 인증 주체를 서비스에 전달하도록 변경했으며, 서비스는 일반 회원·로그인 익명·비회원 익명·삭제 댓글의 수정 가능 여부를 서버 권한 매트릭스와 동일하게 계산합니다. + - 프런트엔드는 `isAnonymous`나 로그인 여부를 자체 추정하지 않고 서버의 `canEdit`, `requiresPassword`를 사용해 수정 버튼과 비밀번호 입력을 표시합니다. + - `CommentReadTest`에 작성자/타 사용자별 루트 댓글 권한, 분리 대댓글 권한, 내부 소유자 식별자 비노출 검증을 추가했습니다. + - 검증 결과: `CommentReadTest`, `CommentCreateTest`, `CommentUpdateTest` 통과, 백엔드 `spotlessCheck` 통과, 프런트엔드 `npm run build` 통과했습니다. + - 전체 `npm run lint`는 이번 변경 파일 외의 기존 오류 6건(`ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지) 때문에 실패했습니다. 이번 변경 파일은 별도 ESLint 검사로 신규 오류가 없음을 확인합니다. +- **댓글 생성 트랜잭션 경계 리뷰 이슈 기록 (2026-09-06)**: + - `TransactionTemplate`의 기본 전파가 `REQUIRED`라 `CommentService.createComment`의 기존 트랜잭션에 참여하는 구조임을 확인했습니다. + +- **Sprint 03 댓글 수정 MockMvc 통합 테스트 보강 (2026-09-06)**: + - `CommentControllerTest`에 `PUT /api/v1/comments/{commentId}`의 정상 회원 수정, 타 회원 권한 거부, 공백 본문 검증, 잘못된 JSON 역직렬화, CSRF 누락, 비회원 익명 비밀번호 수정 시나리오를 추가했습니다. + - 정상 요청은 `@AuthenticationPrincipal` 주입과 JSON 응답뿐 아니라 실제 댓글 본문이 DB에 반영됐는지도 확인합니다. + - 테스트 과정에서 `HttpMessageNotReadableException`이 공통 예외 처리에 누락되어 잘못된 JSON이 `500 SERVER_001`로 반환되는 문제를 발견했습니다. `GlobalExceptionHandler`에서 이를 `400 COMMON_001`로 변환하도록 보강했습니다. + - 검증 결과: `CommentControllerTest` 9건과 `CommentUpdateTest` 16건, 총 25건 통과 및 `spotlessCheck` 통과했습니다. + - `NOT_SUPPORTED` 또는 `REQUIRES_NEW`로 단순 변경하면 `CommentCreateTest`·`CommentUpdateTest`의 미커밋 픽스처를 새 트랜잭션에서 읽지 못해 테스트가 실패합니다. + - 안전한 해결에는 생성 전용 트랜잭션 Bean 분리와 테스트 픽스처의 별도 커밋 경계 조정이 함께 필요합니다. 현재는 동작을 깨뜨리는 부분 수정 대신 후속 작업으로 남겼습니다. +- **댓글 익명 사용자 유형별 삭제 비밀번호 분기 수정 (2026-09-06)**: + - 삭제 핸들러가 `isAnonymous`가 아닌 서버 응답의 `requiresPassword`를 기준으로 동작하도록 변경했습니다. + - 로그인 익명 댓글은 비밀번호 없이 로그인 세션으로 삭제를 요청하고, 비회원 익명 댓글만 비밀번호를 요구합니다. +- **댓글 생성 후 대댓글 미리보기·더보기 상태 동기화 (2026-09-06)**: + - 대댓글 생성 직후 미리보기를 최대 5개로 제한했습니다. + - 증가된 `replyCount`를 기준으로 `hasMoreReplies`를 재계산해 6번째 대댓글부터 더보기 상태가 활성화됩니다. +- **Spike 댓글 시드 충돌 안전성 보강 (2026-09-06)**: + - 기존 `member_id`, `category_id`, `post_id` 고정값에 의존하던 시드를 자연키와 전용 `public_id` 기준으로 변경했습니다. + - 중복 시 기존 회원·카테고리 값을 덮어쓰지 않는 no-op upsert를 적용했습니다. + - 기존 스파이크 게시글 삭제도 고정 PK가 아닌 전용 `public_id`로 제한하고, 생성 후 실제 PK를 변수로 전달하도록 수정했습니다. +- **댓글 생성 트랜잭션 범위 축소 (2026-09-06)**: + - `CommentService`의 클래스-level read-only 트랜잭션을 제거하고 읽기 메서드의 개별 트랜잭션만 유지했습니다. + - `createComment`의 회원 조회·BCrypt 처리는 트랜잭션 외부에서 수행하고, `TransactionTemplate` 내부에서 게시글·부모 잠금, 제한 검증, 저장 및 카운트 증가만 처리하도록 변경했습니다. + - `spotlessApply`는 통과했으며, `CommentCreateTest`는 현재 MySQL 테스트 환경변수 미설정으로 애플리케이션 컨텍스트 초기화 단계에서 실패했습니다. +- **댓글 생성 명령 트랜잭션 별도 Bean 분리 (2026-09-06)**: + - `CommentCommandService`를 신규 Bean으로 분리하고 댓글 저장·잠금·대댓글 제한·게시글 카운트 증가를 해당 Bean의 `@Transactional` 메서드에서 수행하도록 변경했습니다. + - `CommentService`는 회원 조회와 BCrypt 처리 후 명령 Bean을 호출하므로 인증 처리와 짧은 DB 쓰기 트랜잭션의 경계를 분리했습니다. + - 검증: `spotlessApply`, `compileJava` 성공. +- **댓글 수정 동시성 제어 보강 (2026-09-06)**: + - `Comment`에 JPA `@Version`을 추가해 동시 수정 시 낙관적 락으로 선착순 변경만 반영하도록 했습니다. + - 버전 충돌은 `COMMENT_006` Conflict 응답으로 변환해 마지막 요청의 조용한 덮어쓰기를 방지했습니다. + - `spotlessApply`, `compileJava` 검증을 통과했습니다. +- **일반 회원 IP 노출 차단 및 익명 마스킹 일원화 (2026-09-06)**: + - `CommentRepositoryImpl`과 `CommentResponse.from()`에서 익명 댓글(`isAnonymous == true`)일 때만 마스킹된 IP를 응답하고, 일반 회원은 `null`로 차단하여 네트워크 정보 과다 노출을 방지했습니다. + - `CommentResponse.writerName()`에 null 방어 로직을 추가하고 `CommentResponseTest` 단위 테스트 및 `CommentReadTest` 통합 검증을 통과했습니다. + - **Sprint 03 댓글 생성 CR 리뷰 반영: 동시성 테스트 MySQL 엔진 강제 (2026-09-04)**: - `CommentCreateTest`에 `test` 프로필을 명시하고 `SNOWTHING_TEST_DB_URL` 누락 시 H2 fallback 대신 `CustomAuthException(INVALID_INPUT)`으로 즉시 실패하도록 변경했습니다. - GitHub Actions에 MySQL 8.0 테스트 DB URL·계정 환경변수를 명시해 `SELECT FOR UPDATE` 검증이 운영과 동일한 InnoDB에서 수행되도록 했습니다. diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index c461656..cd465aa 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -48,6 +48,8 @@ interface CommentItem { replyCount: number; previewReplies: CommentItem[]; hasMoreReplies: boolean; + canEdit: boolean; + requiresPassword: boolean; createdAt: string; } @@ -73,6 +75,12 @@ interface ReplyPagingState { loading: boolean; } +interface CommentUpdateResponse { + commentId: number; + content: string; + updatedAt: string; +} + export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { const router = useRouter(); const { publicId } = use(params); @@ -92,6 +100,11 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [activeReplyParentId, setActiveReplyParentId] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(""); + const [editCommentPassword, setEditCommentPassword] = useState(""); + const [editCommentError, setEditCommentError] = useState(""); + const [submittingEditComment, setSubmittingEditComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -356,12 +369,15 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: setComments((current) => current.map((comment) => { if (comment.commentId !== parentId) return comment; + const nextReplyCount = comment.replyCount + 1; + const nextPreviewReplies = comment.hasMoreReplies + ? comment.previewReplies + : [...comment.previewReplies, createdComment].slice(0, 5); return { ...comment, - replyCount: comment.replyCount + 1, - previewReplies: comment.hasMoreReplies - ? comment.previewReplies - : [...comment.previewReplies, createdComment], + replyCount: nextReplyCount, + previewReplies: nextPreviewReplies, + hasMoreReplies: comment.hasMoreReplies || nextReplyCount > 5, }; }), ); @@ -389,9 +405,74 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; - const handleDeleteComment = async (commentId: number, isAnonymousWriter: boolean) => { + const handleStartEditComment = (comment: CommentItem) => { + setActiveReplyParentId(null); + setActiveEditCommentId(comment.commentId); + setEditCommentText(comment.content); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleCancelEditComment = () => { + if (submittingEditComment) return; + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleUpdateComment = async (comment: CommentItem) => { + const content = editCommentText.trim(); + const requiresPassword = comment.requiresPassword; + if (!content) { + setEditCommentError("댓글 내용을 입력해주세요."); + return; + } + if (content.length > 1000) { + setEditCommentError("댓글은 1,000자 이하로 입력해주세요."); + return; + } + if (requiresPassword && !editCommentPassword.trim()) { + setEditCommentError("익명 댓글 비밀번호를 입력해주세요."); + return; + } + if (content === comment.content) { + setEditCommentError("변경된 내용이 없습니다."); + return; + } + + setSubmittingEditComment(true); + setEditCommentError(""); + try { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + anonymousPassword: editCommentPassword.trim() || null, + }), + }); + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 수정에 실패했습니다."); + } + + const updated: CommentUpdateResponse = await res.json(); + setComments((current) => updateCommentContent(current, updated.commentId, updated.content)); + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + } catch (error) { + setEditCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingEditComment(false); + } + }; + + const handleDeleteComment = async (comment: CommentItem) => { let anonymousPassword = ""; - if (isAnonymousWriter) { + if (comment.requiresPassword) { const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요."); if (!input) return; anonymousPassword = input; @@ -400,12 +481,10 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } try { - const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentId), { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - anonymousPassword: anonymousPassword || null, - }), + body: JSON.stringify({ anonymousPassword: anonymousPassword || null }), }); if (res.ok) { await fetchComments(); @@ -573,6 +652,16 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} + activeEditCommentId={activeEditCommentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} submittingComment={submittingComment} handleDeleteComment={handleDeleteComment} handleLoadMoreReplies={handleLoadMoreReplies} @@ -621,6 +710,16 @@ function CommentRow({ setReplyAnonPassword, handleCreateComment, submittingComment, + activeEditCommentId, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, handleDeleteComment, handleLoadMoreReplies, isLoadingReplies, @@ -636,10 +735,22 @@ function CommentRow({ setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; submittingComment: boolean; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + activeEditCommentId: number | null; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; + handleDeleteComment: (comment: CommentItem) => Promise; handleLoadMoreReplies: (rootCommentId: number) => Promise; isLoadingReplies: boolean; }) { + const canEdit = canEditComment(item); + const isEditing = activeEditCommentId === item.commentId; const toggleReplyEditor = () => { setActiveReplyParentId(activeReplyParentId === item.commentId ? null : item.commentId); }; @@ -651,18 +762,39 @@ function CommentRow({ {getWriterName(item)} {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} -

{item.content}

- -
- - {!item.isDeleted && ( - - )} -
+ {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( + <> +

{item.content}

+
+ + {canEdit && ( + + )} + {!item.isDeleted && ( + + )} +
+ + )} {item.previewReplies.length > 0 && (
@@ -671,6 +803,16 @@ function CommentRow({ key={reply.commentId} item={reply} onReply={toggleReplyEditor} + isEditing={activeEditCommentId === reply.commentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} handleDeleteComment={handleDeleteComment} /> ))} @@ -740,12 +882,34 @@ function CommentRow({ function ReplyRow({ item, onReply, + isEditing, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, handleDeleteComment, }: { item: CommentItem; onReply: () => void; - handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; + isEditing: boolean; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; + handleDeleteComment: (comment: CommentItem) => Promise; }) { + const canEdit = canEditComment(item); + return (
@@ -754,13 +918,33 @@ function ReplyRow({ {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-

{item.content}

- {!item.isDeleted && ( + {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( +

{item.content}

+ )} + {!item.isDeleted && !isEditing && (
- + )} +
@@ -769,7 +953,93 @@ function ReplyRow({ ); } +function CommentEditForm({ + comment, + content, + setContent, + password, + setPassword, + error, + submitting, + requiresPassword, + onCancel, + onSubmit, +}: { + comment: CommentItem; + content: string; + setContent: (content: string) => void; + password: string; + setPassword: (password: string) => void; + error: string; + submitting: boolean; + requiresPassword: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + const contentId = `comment-edit-content-${comment.commentId}`; + const passwordId = `comment-edit-password-${comment.commentId}`; + + return ( +
+ +