diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java index c591bc2ad..e788a1d05 100644 --- a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -49,4 +50,23 @@ public ResponseEntity createComment( return ResponseEntity.status(HttpStatus.CREATED).build(); } + @Operation(summary = "게시글 댓글 삭제", description = "게시글 댓글을 삭제한다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "게시글 댓글 삭제 성공"), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글, 존재하지 않는 댓글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @DeleteMapping("/{postId}/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable @Parameter(description = "게시글 ID") final Long postId, + @PathVariable @Parameter(description = "댓글 ID") final Long commentId, + @Auth final Member member + ) { + postCommentService.deleteComment(postId, commentId, member); + return ResponseEntity.noContent().build(); + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java index e08724290..94fe03637 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java @@ -3,6 +3,8 @@ import com.votogether.domain.common.BaseEntity; import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.exception.CommentExceptionType; +import com.votogether.exception.BadRequestException; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -11,6 +13,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -45,4 +48,16 @@ private Comment(final Post post, final Member member, final String content) { this.content = new Content(content); } + public void validateWriter(final Member member) { + if (!Objects.equals(this.member.getId(), member.getId())) { + throw new BadRequestException(CommentExceptionType.NOT_WRITER); + } + } + + public void validateBelong(final Post post) { + if (!Objects.equals(this.post.getId(), post.getId())) { + throw new BadRequestException(CommentExceptionType.NOT_BELONG_POST); + } + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java index eadbe1df0..e635a99a4 100644 --- a/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java +++ b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java @@ -6,7 +6,10 @@ @Getter public enum CommentExceptionType implements ExceptionType { - INVALID_CONTENT_LENGTH(2000, "유효하지 않은 댓글 길이입니다."); + INVALID_CONTENT_LENGTH(2000, "유효하지 않은 댓글 길이입니다."), + COMMENT_NOT_FOUND(2001, "해당 댓글이 존재하지 않습니다."), + NOT_BELONG_POST(2002, "댓글의 게시글 정보와 일치하지 않습니다."), + NOT_WRITER(2003, "댓글 작성자가 아닙니다."); private final int code; private final String message; diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java index 1550fda38..a03dd0153 100644 --- a/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java +++ b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java @@ -4,7 +4,9 @@ import com.votogether.domain.post.dto.request.CommentRegisterRequest; import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.exception.CommentExceptionType; import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.repository.CommentRepository; import com.votogether.domain.post.repository.PostRepository; import com.votogether.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -16,6 +18,7 @@ public class PostCommentService { private final PostRepository postRepository; + private final CommentRepository commentRepository; @Transactional public void createComment( @@ -34,4 +37,17 @@ public void createComment( post.addComment(comment); } + @Transactional + public void deleteComment(final Long postId, final Long commentId, final Member member) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); + final Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND)); + + comment.validateBelong(post); + comment.validateWriter(member); + + commentRepository.delete(comment); + } + } diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java index 887e3c85d..9b75fa295 100644 --- a/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java +++ b/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java @@ -2,10 +2,13 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import com.votogether.domain.member.entity.Member; import com.votogether.domain.member.service.MemberService; import com.votogether.domain.post.dto.request.CommentRegisterRequest; import com.votogether.domain.post.service.PostCommentService; @@ -40,7 +43,7 @@ class PostCommentControllerTest { TokenProcessor tokenProcessor; @BeforeEach - void setUp(final WebApplicationContext webApplicationContext) { + void setUp(WebApplicationContext webApplicationContext) { RestAssuredMockMvc.standaloneSetup(new PostCommentController(postCommentService)); RestAssuredMockMvc.webAppContextSetup(webApplicationContext); } @@ -72,12 +75,13 @@ void invalidIDType(String id) throws Exception { @ParameterizedTest @NullAndEmptySource @DisplayName("댓글 내용이 존재하지 않으면 400을 응답한다.") - void emptyContent(final String content) throws Exception { + void emptyContent(String content) throws Exception { // given TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); given(tokenProcessor.resolveToken(anyString())).willReturn("token"); given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest(content); // when, then @@ -100,7 +104,10 @@ void createComment() throws Exception { given(tokenProcessor.resolveToken(anyString())).willReturn("token"); given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest("댓글입니다."); + willDoNothing().given(postCommentService) + .createComment(any(Member.class), anyLong(), any(CommentRegisterRequest.class)); // when, then RestAssuredMockMvc.given().log().all() @@ -114,4 +121,69 @@ void createComment() throws Exception { } + @Nested + @DisplayName("게시글 댓글 삭제") + class DeleteComment { + + @ParameterizedTest + @ValueSource(strings = {"@", "a", "가"}) + @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + void invalidPostIDType(String postId) throws Exception { + // given + TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); + given(tokenProcessor.resolveToken(anyString())).willReturn("token"); + given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); + given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().delete("/posts/{postId}/comments/{commentId}", postId, 1L) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(-9998)) + .body("message", containsString("postId는 Long 타입이 필요합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"@", "a", "가"}) + @DisplayName("댓글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + void invalidCommentIDType(String commentId) throws Exception { + // given + TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); + given(tokenProcessor.resolveToken(anyString())).willReturn("token"); + given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); + given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().delete("/posts/{postId}/comments/{commentId}", 1L, commentId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(-9998)) + .body("message", containsString("commentId는 Long 타입이 필요합니다.")); + } + + @Test + @DisplayName("댓글을 정상적으로 삭제하면 204를 응답한다.") + void deleteComment() throws Exception { + // given + TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); + given(tokenProcessor.resolveToken(anyString())).willReturn("token"); + given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); + given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + + willDoNothing().given(postCommentService).deleteComment(anyLong(), anyLong(), any(Member.class)); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().delete("/posts/{postId}/comments/{commentId}", 1L, 1L) + .then().log().all() + .status(HttpStatus.NO_CONTENT); + } + + } + } diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java index a8ecda16b..4df44c37b 100644 --- a/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java @@ -1,6 +1,5 @@ package com.votogether.domain.post.entity; -import static com.votogether.fixtures.MemberFixtures.FEMALE_20; import static com.votogether.fixtures.MemberFixtures.MALE_30; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; diff --git a/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java b/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java index 3ccb5a4f6..8ebbe2aa3 100644 --- a/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java +++ b/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.PostBody; import com.votogether.exception.BadRequestException; @@ -9,6 +10,7 @@ import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; class CommentTest { @@ -39,4 +41,69 @@ void invalidContentLength() { .hasMessage("유효하지 않은 댓글 길이입니다."); } + @Test + @DisplayName("댓글 작성자가 아니라면 예외를 던진다.") + void invalidWriter() { + // given + Member member = MemberFixtures.FEMALE_20.get(); + PostBody body = PostBody.builder() + .title("title") + .content("content") + .build(); + Post post = Post.builder() + .writer(member) + .postBody(body) + .deadline(LocalDateTime.now()) + .build(); + Comment comment = Comment.builder() + .member(member) + .post(post) + .content("content") + .build(); + + ReflectionTestUtils.setField(member, "id", 1L); + + // when, then + assertThatThrownBy(() -> comment.validateWriter(MemberFixtures.MALE_20.get())) + .isInstanceOf(BadRequestException.class) + .hasMessage("댓글 작성자가 아닙니다."); + } + + @Test + @DisplayName("작성되어 있는 게시글이 아니라면 예외를 던진다.") + void invalidPost() { + // given + Member member = MemberFixtures.FEMALE_20.get(); + PostBody bodyA = PostBody.builder() + .title("title") + .content("content") + .build(); + Post postA = Post.builder() + .writer(member) + .postBody(bodyA) + .deadline(LocalDateTime.now()) + .build(); + PostBody bodyB = PostBody.builder() + .title("title") + .content("content") + .build(); + Post postB = Post.builder() + .writer(member) + .postBody(bodyB) + .deadline(LocalDateTime.now()) + .build(); + Comment comment = Comment.builder() + .member(member) + .post(postA) + .content("content") + .build(); + + ReflectionTestUtils.setField(postA, "id", 1L); + + // when, then + assertThatThrownBy(() -> comment.validateBelong(postB)) + .isInstanceOf(BadRequestException.class) + .hasMessage("댓글의 게시글 정보와 일치하지 않습니다."); + } + } diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java index c61a94ff9..f6580a61a 100644 --- a/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java +++ b/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java @@ -9,8 +9,10 @@ import com.votogether.domain.post.dto.request.CommentRegisterRequest; import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.PostBody; +import com.votogether.domain.post.entity.comment.Comment; import com.votogether.domain.post.repository.CommentRepository; import com.votogether.domain.post.repository.PostRepository; +import com.votogether.exception.BadRequestException; import com.votogether.exception.NotFoundException; import com.votogether.fixtures.MemberFixtures; import java.time.LocalDateTime; @@ -74,4 +76,128 @@ void createComment() { } + @Nested + @DisplayName("게시글 댓글 삭제") + class DeleteComment { + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void emptyPost() { + // given + Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(-1L, 1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("해당 게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("존재하지 않는 댓글이라면 예외를 던진다.") + void emptyComment() { + // given + Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + Post post = postRepository.save( + Post.builder() + .writer(member) + .postBody(PostBody.builder().title("title").content("content").build()) + .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) + .build() + ); + + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), -1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("해당 댓글이 존재하지 않습니다."); + } + + @Test + @DisplayName("댓글의 게시글과 일치하지 않으면 예외를 던진다.") + void invalidBelongPost() { + // given + Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + Post postA = postRepository.save( + Post.builder() + .writer(member) + .postBody(PostBody.builder().title("titleA").content("contentA").build()) + .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) + .build() + ); + Post postB = postRepository.save( + Post.builder() + .writer(member) + .postBody(PostBody.builder().title("titleB").content("contentB").build()) + .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) + .build() + ); + Comment comment = commentRepository.save( + Comment.builder() + .member(member) + .post(postA) + .content("comment") + .build() + ); + + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(postB.getId(), comment.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("댓글의 게시글 정보와 일치하지 않습니다."); + } + + @Test + @DisplayName("댓글의 작성자가 아니라면 예외르 던진다.") + void invalidWriter() { + // given + Member memberA = memberRepository.save(MemberFixtures.MALE_20.get()); + Member memberB = memberRepository.save(MemberFixtures.FEMALE_20.get()); + Post post = postRepository.save( + Post.builder() + .writer(memberA) + .postBody(PostBody.builder().title("titleA").content("contentA").build()) + .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) + .build() + ); + Comment comment = commentRepository.save( + Comment.builder() + .member(memberB) + .post(post) + .content("comment") + .build() + ); + + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), memberA)) + .isInstanceOf(BadRequestException.class) + .hasMessage("댓글 작성자가 아닙니다."); + } + + @Test + @DisplayName("게시글의 댓글을 삭제한다.") + void deleteComment() { + // given + Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + Post post = postRepository.save( + Post.builder() + .writer(member) + .postBody(PostBody.builder().title("titleA").content("contentA").build()) + .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) + .build() + ); + Comment comment = commentRepository.save( + Comment.builder() + .member(member) + .post(post) + .content("comment") + .build() + ); + + // when + postCommentService.deleteComment(post.getId(), comment.getId(), member); + + // then + assertThat(commentRepository.findAll()).isEmpty(); + } + + } + }