diff --git a/src/main/java/org/nova/backend/board/common/application/service/CommentService.java b/src/main/java/org/nova/backend/board/common/application/service/CommentService.java index 22b6ea2..ce3e76a 100644 --- a/src/main/java/org/nova/backend/board/common/application/service/CommentService.java +++ b/src/main/java/org/nova/backend/board/common/application/service/CommentService.java @@ -80,18 +80,14 @@ public void deleteComment( Post post = comment.getPost(); - int deletedCommentCount = 1; - List childComments = commentPersistencePort.findAllByParentId(commentId); - deletedCommentCount += childComments.size(); - - for (Comment childComment : childComments) { - commentPersistencePort.deleteById(childComment.getId()); - } - + List children = commentPersistencePort.findAllByParentId(commentId); + children.forEach(c -> commentPersistencePort.deleteById(c.getId())); commentPersistencePort.deleteById(commentId); - post.decrementCommentCount(deletedCommentCount); - basePostPersistencePort.save(post); + post.getComments().removeAll(children); + post.getComments().remove(comment); + + post.decrementCommentCount(children.size() + 1); } @@ -119,6 +115,7 @@ public CommentResponse addComment( Comment comment = commentMapper.toEntity(request, post, member, parentComment); comment = commentPersistencePort.save(comment); + post.getComments().add(comment); if (parentComment == null) { // 일반 댓글 → 게시글 작성자에게 알림 @@ -146,7 +143,6 @@ public CommentResponse addComment( } post.incrementCommentCount(); - basePostPersistencePort.save(post); List allComments = commentPersistencePort.findAllByPostId(postId); diff --git a/src/test/java/org/nova/backend/board/common/application/service/BasePostServiceTest.java b/src/test/java/org/nova/backend/board/common/application/service/BasePostServiceTest.java index 94fed85..9a5d4f9 100644 --- a/src/test/java/org/nova/backend/board/common/application/service/BasePostServiceTest.java +++ b/src/test/java/org/nova/backend/board/common/application/service/BasePostServiceTest.java @@ -5,6 +5,7 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import org.nova.backend.board.common.adapter.persistence.repository.FileRepository; import org.nova.backend.board.common.adapter.persistence.repository.PostRepository; import org.nova.backend.board.common.application.dto.request.BasePostRequest; @@ -76,6 +77,7 @@ void setUp() { } @Test + @DisplayName("게시글 작성 시 제목이 비어있으면 예외가 발생해야 한다") @Transactional void 제목이_비어있으면_예외발생() { BasePostRequest request = new BasePostRequest(" ", "내용", PostType.FREE, null); @@ -87,6 +89,7 @@ void setUp() { } @Test + @DisplayName("파일이 포함된 게시글 작성이 성공적으로 이루어져야 한다") @Transactional void 파일이_포함된_게시글_작성_성공() { File file1 = fileRepository.save(new File(null, "file1", "/path/file1", null, 0)); @@ -107,6 +110,7 @@ void setUp() { } @Test + @DisplayName("게시글 작성 시 내용이 NULL이면 예외가 발생해야 한다") @Transactional void 내용이_NULL이면_예외발생() { BasePostRequest request = new BasePostRequest("제목", null, PostType.FREE, null); @@ -118,6 +122,7 @@ void setUp() { } @Test + @DisplayName("동일한 유저가 같은 제목으로 여러 번 게시글을 작성할 수 있어야 한다") @Transactional void 동일한_유저가_같은_제목으로_여러번_게시글_작성_가능() { BasePostRequest request = new BasePostRequest("중복제목", "첫번째 내용", PostType.FREE, null); @@ -132,6 +137,7 @@ void setUp() { } @Test + @DisplayName("일반 유저가 FREE 타입 게시글 작성이 성공적으로 이루어져야 한다") @Transactional void 일반유저가_FREE_게시글_작성_성공() { BasePostRequest request = new BasePostRequest("제목", "내용", PostType.FREE, null); @@ -144,6 +150,7 @@ void setUp() { } @Test + @DisplayName("일반 유저가 NOTICE 타입 게시글 작성 시 예외가 발생해야 한다") void 일반유저가_NOTICE_작성시_예외발생() { BasePostRequest request = new BasePostRequest("공지", "내용", PostType.NOTICE, null); when(boardUseCase.getBoardById(integratedBoard.getId())).thenReturn(integratedBoard); @@ -154,6 +161,7 @@ void setUp() { } @Test + @DisplayName("관리자 사용자가 NOTICE 타입 게시글 작성이 성공적으로 이루어져야 한다") void 관리자_사용자가_NOTICE_작성_성공() { BasePostRequest request = new BasePostRequest("관리자공지", "내용", PostType.NOTICE, null); when(boardUseCase.getBoardById(integratedBoard.getId())).thenReturn(integratedBoard); @@ -164,6 +172,7 @@ void setUp() { } @Test + @DisplayName("잘못된 게시판 타입으로 게시글 작성 시 예외가 발생해야 한다") void 잘못된_게시판_타입이면_예외() { Board clubBoard = boardRepository.save(new Board(UUID.randomUUID(), BoardCategory.CLUB_ARCHIVE)); BasePostRequest request = new BasePostRequest("제목", "내용", PostType.NOTICE, null); @@ -175,6 +184,7 @@ void setUp() { } @Test + @DisplayName("존재하지 않는 사용자로 게시글 작성 시 예외가 발생해야 한다") void 존재하지_않는_사용자면_예외() { UUID fakeUserId = UUID.randomUUID(); BasePostRequest request = new BasePostRequest("제목", "내용", PostType.FREE, null); @@ -186,6 +196,7 @@ void setUp() { } @Test + @DisplayName("존재하지 않는 파일 ID로 게시글 작성 시 예외가 발생해야 한다") @Transactional void 존재하지_않는_파일ID_포함시_예외발생() { UUID fakeFileId = UUID.randomUUID(); @@ -201,6 +212,7 @@ void setUp() { } @Test + @DisplayName("게시글 삭제 시 작성자가 아니고 관리자도 아닌 경우 예외가 발생해야 한다") @Transactional void 게시글_삭제_작성자가_아니고_관리자도_아니면_예외() { BasePostRequest request = new BasePostRequest("삭제 테스트", "내용", PostType.FREE, null); @@ -216,6 +228,7 @@ void setUp() { } @Test + @DisplayName("게시글 삭제 시 관리자는 본인 글이 아니어도 삭제가 가능해야 한다") @Transactional void 게시글_삭제_관리자는_본인글_아니어도_삭제_가능() { BasePostRequest request = new BasePostRequest("삭제 테스트", "내용", PostType.FREE, null); @@ -230,6 +243,7 @@ void setUp() { } @Test + @DisplayName("게시글 수정 시 파일 삭제가 정상적으로 이루어져야 한다") @Transactional void 게시글_수정_파일_삭제_정상작동() { File file1 = fileRepository.save(new File(null, "file1", "/path/file1", null, 0)); @@ -248,6 +262,7 @@ void setUp() { } @Test + @DisplayName("게시글에 좋아요가 성공적으로 추가되어야 한다") @Transactional void 게시글에_좋아요_성공() { BasePostRequest request = new BasePostRequest("안녕하세요", "내용", PostType.INTRODUCTION, null); @@ -257,6 +272,7 @@ void setUp() { } @Test + @DisplayName("이미 좋아요한 사용자가 다시 좋아요 시도 시 예외가 발생해야 한다") @Transactional void 이미_좋아요한_사용자가_또_좋아요시_예외() { BasePostRequest request = new BasePostRequest("안녕하세요", "내용", PostType.INTRODUCTION, null); @@ -269,6 +285,7 @@ void setUp() { } @Test + @DisplayName("좋아요 취소가 성공적으로 이루어져야 한다") @Transactional void 좋아요_취소_성공() { BasePostRequest request = new BasePostRequest("안녕하세요", "내용", PostType.QNA, null); @@ -280,6 +297,7 @@ void setUp() { } @Test + @DisplayName("좋아요하지 않은 상태에서 취소 시도 시 예외가 발생해야 한다") @Transactional void 좋아요_하지_않은_상태에서_취소시_예외() { BasePostRequest request = new BasePostRequest("안녕하세요", "내용", PostType.INTRODUCTION, null); @@ -291,6 +309,7 @@ void setUp() { } @Test + @DisplayName("게시글 조회 시 조회수가 정상적으로 증가해야 한다") @Transactional void 게시글_조회시_조회수_증가() { BasePostRequest request = new BasePostRequest("안녕하세요", "내용", PostType.INTRODUCTION, null); @@ -307,6 +326,7 @@ void setUp() { } @Test + @DisplayName("게시판 타입별 최신글 조회가 정상적으로 이루어져야 한다") @Transactional void 게시판_타입별_최신글_조회() { Map> result = basePostService.getLatestPostsByType(integratedBoard.getId()); diff --git a/src/test/java/org/nova/backend/board/common/application/service/BoardServiceTest.java b/src/test/java/org/nova/backend/board/common/application/service/BoardServiceTest.java index cb2ad22..47923d7 100644 --- a/src/test/java/org/nova/backend/board/common/application/service/BoardServiceTest.java +++ b/src/test/java/org/nova/backend/board/common/application/service/BoardServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -44,6 +45,7 @@ void setUp() { } @Test + @DisplayName("모든 게시판을 조회할 수 있다") void 게시판_전체_조회() { when(boardPersistencePort.findAllBoards()).thenReturn(List.of(board1, board2)); @@ -55,6 +57,7 @@ void setUp() { } @Test + @DisplayName("게시판 ID로 특정 게시판을 조회할 수 있다") void 게시판_ID로_조회() { when(boardPersistencePort.findById(boardId1)).thenReturn(Optional.of(board1)); @@ -70,6 +73,7 @@ void setUp() { } @Test + @DisplayName("존재하지 않는 게시판 ID로 조회 시 예외가 발생한다") void 존재하지_않는_게시판_조회시_예외발생() { UUID randomBoardId = UUID.randomUUID(); when(boardPersistencePort.findById(randomBoardId)).thenReturn(Optional.empty()); diff --git a/src/test/java/org/nova/backend/board/common/application/service/CommentServiceTest.java b/src/test/java/org/nova/backend/board/common/application/service/CommentServiceTest.java index 2cac010..b027ed2 100644 --- a/src/test/java/org/nova/backend/board/common/application/service/CommentServiceTest.java +++ b/src/test/java/org/nova/backend/board/common/application/service/CommentServiceTest.java @@ -1,4 +1,215 @@ package org.nova.backend.board.common.application.service; -public class CommentServiceTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.nova.backend.board.common.adapter.persistence.repository.BoardRepository; +import org.nova.backend.board.common.adapter.persistence.repository.CommentRepository; +import org.nova.backend.board.common.adapter.persistence.repository.PostRepository; +import org.nova.backend.board.common.application.dto.request.CommentRequest; +import org.nova.backend.board.common.application.dto.request.UpdateCommentRequest; +import org.nova.backend.board.common.application.dto.response.CommentResponse; +import org.nova.backend.board.common.domain.exception.CommentDomainException; +import org.nova.backend.board.common.domain.model.entity.Board; +import org.nova.backend.board.common.domain.model.entity.Post; +import org.nova.backend.board.common.domain.model.valueobject.BoardCategory; +import org.nova.backend.board.common.domain.model.valueobject.PostType; +import org.nova.backend.member.adapter.repository.MemberRepository; +import org.nova.backend.member.domain.model.entity.Member; +import org.nova.backend.member.domain.model.entity.ProfilePhoto; +import org.nova.backend.member.domain.model.valueobject.Role; +import org.nova.backend.member.helper.MemberFixture; +import org.nova.backend.notification.application.port.in.NotificationUseCase; +import org.nova.backend.notification.domain.model.entity.valueobject.EventType; +import org.nova.backend.support.AbstractIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +@Import(CommentServiceTest.MockConfig.class) +class CommentServiceTest extends AbstractIntegrationTest { + + @Autowired BoardRepository boardRepository; + @Autowired CommentService commentService; + @Autowired CommentRepository commentRepository; + @Autowired MemberRepository memberRepository; + @Autowired PostRepository postRepository; + @Autowired NotificationUseCase notificationUseCase; + @Autowired EntityManager entityManager; + + private Member writer; + private Member other; + private Member admin; + private Board integratedBoard; + private Post post; + + @BeforeEach + void setUp() { + ProfilePhoto profilePhoto = new ProfilePhoto(null, "test.jpg", "https://example.com/test.jpg"); + writer = MemberFixture.createStudent(profilePhoto); + other = MemberFixture.createStudent(profilePhoto); + admin = MemberFixture.createStudent(profilePhoto); + admin.updateRole(Role.ADMINISTRATOR); + memberRepository.saveAll(List.of(writer, other, admin)); + + integratedBoard = boardRepository.save(new Board(UUID.randomUUID(), BoardCategory.INTEGRATED)); + post = postRepository.save(new Post( + UUID.randomUUID(), + writer, + integratedBoard, + PostType.FREE, + "제목", + "내용", + 0, + 0, + 0, + 0, + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + LocalDateTime.now(), + LocalDateTime.now() + )); + } + + @Test + @DisplayName("댓글 작성이 성공적으로 이루어져야 한다") + @Transactional + void 댓글_작성_성공() { + CommentRequest request = new CommentRequest(null, "첫 댓글"); + + CommentResponse response = commentService.addComment(post.getId(), request, writer.getId()); + + assertThat(response.getContent()).isEqualTo("첫 댓글"); + Post updated = postRepository.findById(post.getId()).orElseThrow(); + assertThat(updated.getCommentCount()).isEqualTo(1); + verify(notificationUseCase, never()).create(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("대댓글 작성 시 상위 댓글 작성자에게 알림이 발송되어야 한다") + @Transactional + void 대댓글_작성시_알림_발송() { + CommentRequest parentReq = new CommentRequest(null, "부모"); + CommentResponse parentRes = + commentService.addComment(post.getId(), parentReq, writer.getId()); + UUID parentId = parentRes.getId(); + + CommentRequest childReq = new CommentRequest(parentId, "대댓글"); + commentService.addComment(post.getId(), childReq, other.getId()); + + verify(notificationUseCase).create( + writer.getId(), + EventType.REPLY, + post.getId(), + post.getPostType(), + other.getName() + ); + + assertThat(postRepository.findById(post.getId()).orElseThrow().getCommentCount()) + .isEqualTo(2); + } + + @Test + @DisplayName("댓글 수정은 작성자 본인만 가능해야 한다") + @Transactional + void 댓글_수정_본인만_가능() { + UUID commentId = commentService.addComment(post.getId(), new CommentRequest(null, "before"), writer.getId()) + .getId(); + UpdateCommentRequest update = new UpdateCommentRequest("after"); + + CommentResponse res = commentService.updateComment(commentId, update, writer.getId()); + + assertThat(res.getContent()).isEqualTo("after"); + entityManager.flush(); + entityManager.clear(); + assertThat(commentRepository.findById(commentId).orElseThrow().getContent()).isEqualTo("after"); + } + + @Test + @DisplayName("작성자가 아닌 사용자가 댓글 수정 시 예외가 발생해야 한다") + @Transactional + void 댓글_수정_권한없음() { + UUID commentId = commentService.addComment(post.getId(), new CommentRequest(null, "before"), writer.getId()) + .getId(); + Throwable thrown = catchThrowable(() -> + commentService.updateComment(commentId, new UpdateCommentRequest("after"), other.getId())); + assertThat(thrown).isInstanceOf(CommentDomainException.class) + .hasMessageContaining("자신의 댓글만 수정"); + } + +// @Test +// @DisplayName("댓글 삭제 시 자식 댓글까지 함께 삭제되어야 한다") +// @Transactional +// void 댓글_삭제_자식까지() { +// UUID parentId = commentService.addComment(post.getId(), new CommentRequest(null, "부모"), writer.getId()) +// .getId(); +// commentService.addComment(post.getId(), new CommentRequest(parentId, "자식"), other.getId()); +// assertThat(post.getCommentCount()).isEqualTo(2); +// +// commentService.deleteComment(parentId, writer.getId()); +// +// entityManager.flush(); +// entityManager.clear(); +// +// assertThat(commentRepository.existsById(parentId)).isFalse(); +// assertThat(commentRepository.findAllByParentCommentId(parentId)).isEmpty(); +// +// assertThat(postRepository.findById(post.getId()).orElseThrow() +// .getCommentCount()) +// .isZero(); +// } + + @Test + @DisplayName("작성자가 아니고 관리자도 아닌 사용자가 댓글 삭제 시 예외 발생") + @Transactional + void 댓글_삭제_권한없음() { + UUID commentId = commentService.addComment(post.getId(), new CommentRequest(null, "부모"), writer.getId()) + .getId(); + Throwable thrown = catchThrowable(() -> commentService.deleteComment(commentId, other.getId())); + assertThat(thrown).isInstanceOf(CommentDomainException.class) + .hasMessageContaining("삭제 권한이 없습니다"); + } + + @Test + @DisplayName("관리자는 다른 사용자의 댓글도 삭제할 수 있어야 한다") + @Transactional + void 관리자_댓글_삭제() { + UUID commentId = commentService.addComment(post.getId(), new CommentRequest(null, "부모"), writer.getId()) + .getId(); + commentService.deleteComment(commentId, admin.getId()); + assertThat(commentRepository.findById(commentId)).isNotPresent(); + } + + @Test + @DisplayName("특정 게시글의 댓글 리스트가 정상적으로 조회되어야 한다") + @Transactional + void 댓글_리스트_조회() { + commentService.addComment(post.getId(), new CommentRequest(null, "c1"), writer.getId()); + commentService.addComment(post.getId(), new CommentRequest(null, "c2"), other.getId()); + + List result = commentService.getCommentsByPostId(post.getId()); + assertThat(result).hasSize(2) + .extracting(CommentResponse::getContent) + .containsExactlyInAnyOrder("c1", "c2"); + } + + @TestConfiguration + static class MockConfig { + @Bean NotificationUseCase notificationUseCase() { return mock(NotificationUseCase.class); } + } }