From 1ccb1e4db9fac552faf3d15eb22a083bd6963f55 Mon Sep 17 00:00:00 2001 From: haerong22 Date: Mon, 23 Jan 2023 02:51:45 +0900 Subject: [PATCH] #16 board: child comment - logic implementation --- .../example/board/dto/ArticleCommentDto.java | 12 +- .../dto/request/ArticleCommentRequest.java | 9 +- .../dto/response/ArticleCommentResponse.java | 25 ++- .../response/ArticleWithCommentsResponse.java | 34 +++- .../board/service/ArticleCommentService.java | 9 +- .../ArticleCommentControllerTest.java | 22 +++ .../ArticleWithCommentsResponseTest.java | 181 ++++++++++++++++++ .../service/ArticleCommentServiceTest.java | 82 ++++---- 8 files changed, 324 insertions(+), 50 deletions(-) create mode 100644 board/src/test/java/com/example/board/dto/response/ArticleWithCommentsResponseTest.java diff --git a/board/src/main/java/com/example/board/dto/ArticleCommentDto.java b/board/src/main/java/com/example/board/dto/ArticleCommentDto.java index 87ac2117..a4032797 100644 --- a/board/src/main/java/com/example/board/dto/ArticleCommentDto.java +++ b/board/src/main/java/com/example/board/dto/ArticleCommentDto.java @@ -10,6 +10,7 @@ public record ArticleCommentDto( Long id, Long articleId, UserAccountDto userAccountDto, + Long parentCommentId, String content, LocalDateTime createdAt, String createdBy, @@ -18,11 +19,15 @@ public record ArticleCommentDto( ) { public static ArticleCommentDto of(Long articleId, UserAccountDto userAccountDto, String content) { - return new ArticleCommentDto(null, articleId, userAccountDto, content, null, null, null, null); + return ArticleCommentDto.of(articleId, userAccountDto, null, content); } - public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) { - return new ArticleCommentDto(id, articleId, userAccountDto, content, createdAt, createdBy, modifiedAt, modifiedBy); + public static ArticleCommentDto of(Long articleId, UserAccountDto userAccountDto, Long parentCommentId, String content) { + return ArticleCommentDto.of(null, articleId, userAccountDto, parentCommentId, content, null, null, null, null); + } + + public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, Long parentCommentId, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) { + return new ArticleCommentDto(id, articleId, userAccountDto, parentCommentId, content, createdAt, createdBy, modifiedAt, modifiedBy); } public static ArticleCommentDto from(ArticleComment entity) { @@ -30,6 +35,7 @@ public record ArticleCommentDto( entity.getId(), entity.getArticle().getId(), UserAccountDto.from(entity.getUserAccount()), + entity.getParentCommentId(), entity.getContent(), entity.getCreatedAt(), entity.getCreatedBy(), diff --git a/board/src/main/java/com/example/board/dto/request/ArticleCommentRequest.java b/board/src/main/java/com/example/board/dto/request/ArticleCommentRequest.java index ddea998b..3932e60f 100644 --- a/board/src/main/java/com/example/board/dto/request/ArticleCommentRequest.java +++ b/board/src/main/java/com/example/board/dto/request/ArticleCommentRequest.java @@ -3,16 +3,21 @@ package com.example.board.dto.request; import com.example.board.dto.ArticleCommentDto; import com.example.board.dto.UserAccountDto; -public record ArticleCommentRequest(Long articleId, String content) { +public record ArticleCommentRequest(Long articleId, Long parentCommentId, String content) { public static ArticleCommentRequest of(Long articleId, String content) { - return new ArticleCommentRequest(articleId, content); + return ArticleCommentRequest.of(articleId, null, content); + } + + public static ArticleCommentRequest of(Long articleId, Long parentCommentId, String content) { + return new ArticleCommentRequest(articleId, parentCommentId, content); } public ArticleCommentDto toDto(UserAccountDto userAccountDto) { return ArticleCommentDto.of( articleId, userAccountDto, + parentCommentId, content ); } diff --git a/board/src/main/java/com/example/board/dto/response/ArticleCommentResponse.java b/board/src/main/java/com/example/board/dto/response/ArticleCommentResponse.java index 4998f287..72a1c48a 100644 --- a/board/src/main/java/com/example/board/dto/response/ArticleCommentResponse.java +++ b/board/src/main/java/com/example/board/dto/response/ArticleCommentResponse.java @@ -4,6 +4,9 @@ import com.example.board.dto.ArticleCommentDto; import java.io.Serializable; import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.Set; +import java.util.TreeSet; public record ArticleCommentResponse( Long id, @@ -11,11 +14,20 @@ public record ArticleCommentResponse( LocalDateTime createdAt, String email, String nickname, - String userId + String userId, + Long parentCommentId, + Set childComments ) implements Serializable { public static ArticleCommentResponse of(Long id, String content, LocalDateTime createdAt, String email, String nickname, String userId) { - return new ArticleCommentResponse(id, content, createdAt, email, nickname, userId); + return ArticleCommentResponse.of(id, content, createdAt, email, nickname, userId, null); + } + + public static ArticleCommentResponse of(Long id, String content, LocalDateTime createdAt, String email, String nickname, String userId, Long parentCommentId) { + Comparator childCommentComparator = Comparator + .comparing(ArticleCommentResponse::createdAt) + .thenComparingLong(ArticleCommentResponse::id); + return new ArticleCommentResponse(id, content, createdAt, email, nickname, userId, parentCommentId, new TreeSet<>(childCommentComparator)); } public static ArticleCommentResponse from(ArticleCommentDto dto) { @@ -24,14 +36,19 @@ public record ArticleCommentResponse( nickname = dto.userAccountDto().userId(); } - return new ArticleCommentResponse( + return ArticleCommentResponse.of( dto.id(), dto.content(), dto.createdAt(), dto.userAccountDto().email(), nickname, - dto.userAccountDto().userId() + dto.userAccountDto().userId(), + dto.parentCommentId() ); } + public boolean hasParentComment() { + return parentCommentId != null; + } + } \ No newline at end of file diff --git a/board/src/main/java/com/example/board/dto/response/ArticleWithCommentsResponse.java b/board/src/main/java/com/example/board/dto/response/ArticleWithCommentsResponse.java index 68563f7b..92e78140 100644 --- a/board/src/main/java/com/example/board/dto/response/ArticleWithCommentsResponse.java +++ b/board/src/main/java/com/example/board/dto/response/ArticleWithCommentsResponse.java @@ -1,12 +1,16 @@ package com.example.board.dto.response; +import com.example.board.dto.ArticleCommentDto; import com.example.board.dto.ArticleWithCommentsDto; import com.example.board.dto.HashtagDto; import java.io.Serializable; import java.time.LocalDateTime; -import java.util.LinkedHashSet; +import java.util.Comparator; +import java.util.Map; import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; import java.util.stream.Collectors; public record ArticleWithCommentsResponse( @@ -42,10 +46,32 @@ public record ArticleWithCommentsResponse( dto.userAccountDto().email(), nickname, dto.userAccountDto().userId(), - dto.articleCommentDtos().stream() - .map(ArticleCommentResponse::from) - .collect(Collectors.toCollection(LinkedHashSet::new)) + organizeChildComments(dto.articleCommentDtos()) ); } + private static Set organizeChildComments(Set dtos) { + Map map = dtos.stream() + .map(ArticleCommentResponse::from) + .collect(Collectors.toMap(ArticleCommentResponse::id, Function.identity())); + + map.values().stream() + .filter(ArticleCommentResponse::hasParentComment) + .forEach(comment -> { + ArticleCommentResponse parentComment = map.get(comment.parentCommentId()); + parentComment.childComments().add(comment); + }); + + return map.values().stream() + .filter(comment -> !comment.hasParentComment()) + .collect(Collectors.toCollection( + () -> new TreeSet<>( + Comparator + .comparing(ArticleCommentResponse::createdAt) + .reversed() + .thenComparingLong(ArticleCommentResponse::id) + ) + )); + } + } \ No newline at end of file diff --git a/board/src/main/java/com/example/board/service/ArticleCommentService.java b/board/src/main/java/com/example/board/service/ArticleCommentService.java index 11f6e661..097fac9a 100644 --- a/board/src/main/java/com/example/board/service/ArticleCommentService.java +++ b/board/src/main/java/com/example/board/service/ArticleCommentService.java @@ -37,7 +37,14 @@ public class ArticleCommentService { try { Article article = articleRepository.getReferenceById(dto.articleId()); UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId()); - articleCommentRepository.save(dto.toEntity(article,userAccount)); + ArticleComment articleComment = dto.toEntity(article, userAccount); + + if (dto.parentCommentId() != null) { + ArticleComment parentComment = articleCommentRepository.getReferenceById(dto.parentCommentId()); + parentComment.addChildComment(articleComment); + } else { + articleCommentRepository.save(articleComment); + } } catch (EntityNotFoundException e) { log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - dto: {}", e.getLocalizedMessage()); } diff --git a/board/src/test/java/com/example/board/controller/ArticleCommentControllerTest.java b/board/src/test/java/com/example/board/controller/ArticleCommentControllerTest.java index c7cf2ab9..da21a21c 100644 --- a/board/src/test/java/com/example/board/controller/ArticleCommentControllerTest.java +++ b/board/src/test/java/com/example/board/controller/ArticleCommentControllerTest.java @@ -90,4 +90,26 @@ class ArticleCommentControllerTest { then(articleCommentService).should().deleteArticleComment(articleCommentId, userId); } + + @WithUserDetails(value = "testId", setupBefore = TestExecutionEvent.TEST_EXECUTION) + @DisplayName("[view][POST] 대댓글 등록 - 정상 호출") + @Test + void givenArticleCommentInfoWithParentCommentId_whenRequesting_thenSavesNewChildComment() throws Exception { + // Given + long articleId = 1L; + ArticleCommentRequest request = ArticleCommentRequest.of(articleId, 1L, "test comment"); + willDoNothing().given(articleCommentService).saveArticleComment(any(ArticleCommentDto.class)); + + // When & Then + mockMvc.perform( + post("/comments/new") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .content(formDataEncoder.encode(request)) + .with(csrf()) + ) + .andExpect(status().is3xxRedirection()) + .andExpect(view().name("redirect:/articles/" + articleId)) + .andExpect(redirectedUrl("/articles/" + articleId)); + then(articleCommentService).should().saveArticleComment(any(ArticleCommentDto.class)); + } } \ No newline at end of file diff --git a/board/src/test/java/com/example/board/dto/response/ArticleWithCommentsResponseTest.java b/board/src/test/java/com/example/board/dto/response/ArticleWithCommentsResponseTest.java new file mode 100644 index 00000000..aad2ba19 --- /dev/null +++ b/board/src/test/java/com/example/board/dto/response/ArticleWithCommentsResponseTest.java @@ -0,0 +1,181 @@ +package com.example.board.dto.response; + +import com.example.board.dto.ArticleCommentDto; +import com.example.board.dto.ArticleWithCommentsDto; +import com.example.board.dto.HashtagDto; +import com.example.board.dto.UserAccountDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Iterator; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DTO - 댓글을 포함한 게시글 응답 테스트") +class ArticleWithCommentsResponseTest { + + @DisplayName("자식 댓글이 없는 게시글 + 댓글 dto를 api 응답으로 변환할 때, 댓글을 시간 내림차순 + ID 오름차순으로 정리한다.") + @Test + void givenArticleWithCommentsDtoWithoutChildComments_whenMapping_thenOrganizesCommentsWithCertainOrder() { + // Given + LocalDateTime now = LocalDateTime.now(); + Set articleCommentDtos = Set.of( + createArticleCommentDto(1L, null, now), + createArticleCommentDto(2L, null, now.plusDays(1L)), + createArticleCommentDto(3L, null, now.plusDays(3L)), + createArticleCommentDto(4L, null, now), + createArticleCommentDto(5L, null, now.plusDays(5L)), + createArticleCommentDto(6L, null, now.plusDays(4L)), + createArticleCommentDto(7L, null, now.plusDays(2L)), + createArticleCommentDto(8L, null, now.plusDays(7L)) + ); + ArticleWithCommentsDto input = createArticleWithCommentsDto(articleCommentDtos); + + // When + ArticleWithCommentsResponse actual = ArticleWithCommentsResponse.from(input); + + // Then + assertThat(actual.articleCommentsResponse()) + .containsExactly( + createArticleCommentResponse(8L, null, now.plusDays(7L)), + createArticleCommentResponse(5L, null, now.plusDays(5L)), + createArticleCommentResponse(6L, null, now.plusDays(4L)), + createArticleCommentResponse(3L, null, now.plusDays(3L)), + createArticleCommentResponse(7L, null, now.plusDays(2L)), + createArticleCommentResponse(2L, null, now.plusDays(1L)), + createArticleCommentResponse(1L, null, now), + createArticleCommentResponse(4L, null, now) + ); + } + + @DisplayName("게시글 + 댓글 dto를 api 응답으로 변환할 때, 댓글 부모 자식 관계를 각각의 규칙으로 정렬하여 정리한다.") + @Test + void givenArticleWithCommentsDto_whenMapping_thenOrganizesParentAndChildCommentsWithCertainOrders() { + // Given + LocalDateTime now = LocalDateTime.now(); + Set articleCommentDtos = Set.of( + createArticleCommentDto(1L, null, now), + createArticleCommentDto(2L, 1L, now.plusDays(1L)), + createArticleCommentDto(3L, 1L, now.plusDays(3L)), + createArticleCommentDto(4L, 1L, now), + createArticleCommentDto(5L, null, now.plusDays(5L)), + createArticleCommentDto(6L, null, now.plusDays(4L)), + createArticleCommentDto(7L, 6L, now.plusDays(2L)), + createArticleCommentDto(8L, 6L, now.plusDays(7L)) + ); + ArticleWithCommentsDto input = createArticleWithCommentsDto(articleCommentDtos); + + // When + ArticleWithCommentsResponse actual = ArticleWithCommentsResponse.from(input); + + // Then + assertThat(actual.articleCommentsResponse()) + .containsExactly( + createArticleCommentResponse(5L, null, now.plusDays(5)), + createArticleCommentResponse(6L, null, now.plusDays(4)), + createArticleCommentResponse(1L, null, now) + ) + .flatExtracting(ArticleCommentResponse::childComments) + .containsExactly( + createArticleCommentResponse(7L, 6L, now.plusDays(2L)), + createArticleCommentResponse(8L, 6L, now.plusDays(7L)), + createArticleCommentResponse(4L, 1L, now), + createArticleCommentResponse(2L, 1L, now.plusDays(1L)), + createArticleCommentResponse(3L, 1L, now.plusDays(3L)) + ); + } + + @DisplayName("게시글 + 댓글 dto를 api 응답으로 변환할 때, 부모 자식 관계 깊이(depth)는 제한이 없다.") + @Test + void givenArticleWithCommentsDto_whenMapping_thenOrganizesParentAndChildCommentsWithoutDepthLimit() { + // Given + LocalDateTime now = LocalDateTime.now(); + Set articleCommentDtos = Set.of( + createArticleCommentDto(1L, null, now), + createArticleCommentDto(2L, 1L, now.plusDays(1L)), + createArticleCommentDto(3L, 2L, now.plusDays(2L)), + createArticleCommentDto(4L, 3L, now.plusDays(3L)), + createArticleCommentDto(5L, 4L, now.plusDays(4L)), + createArticleCommentDto(6L, 5L, now.plusDays(5L)), + createArticleCommentDto(7L, 6L, now.plusDays(6L)), + createArticleCommentDto(8L, 7L, now.plusDays(7L)) + ); + ArticleWithCommentsDto input = createArticleWithCommentsDto(articleCommentDtos); + + // When + ArticleWithCommentsResponse actual = ArticleWithCommentsResponse.from(input); + + // Then + Iterator iterator = actual.articleCommentsResponse().iterator(); + long i = 1L; + while (iterator.hasNext()) { + ArticleCommentResponse articleCommentResponse = iterator.next(); + assertThat(articleCommentResponse) + .hasFieldOrPropertyWithValue("id", i) + .hasFieldOrPropertyWithValue("parentCommentId", i == 1L ? null : i - 1L) + .hasFieldOrPropertyWithValue("createdAt", now.plusDays(i - 1L)); + + iterator = articleCommentResponse.childComments().iterator(); + i++; + } + } + + + private ArticleWithCommentsDto createArticleWithCommentsDto(Set articleCommentDtos) { + return ArticleWithCommentsDto.of( + 1L, + createUserAccountDto(), + articleCommentDtos, + "title", + "content", + Set.of(HashtagDto.of("java")), + LocalDateTime.now(), + "bobby", + LocalDateTime.now(), + "bobby" + ); + } + + private UserAccountDto createUserAccountDto() { + return UserAccountDto.of( + "bobby", + "password", + "bobby@mail.com", + "bobby", + "This is memo", + LocalDateTime.now(), + "bobby", + LocalDateTime.now(), + "bobby" + ); + } + + private ArticleCommentDto createArticleCommentDto(Long id, Long parentCommentId, LocalDateTime createdAt) { + return ArticleCommentDto.of( + id, + 1L, + createUserAccountDto(), + parentCommentId, + "test comment " + id, + createdAt, + "bobby", + createdAt, + "bobby" + ); + } + + private ArticleCommentResponse createArticleCommentResponse(Long id, Long parentCommentId, LocalDateTime createdAt) { + return ArticleCommentResponse.of( + id, + "test comment " + id, + createdAt, + "bobby@mail.com", + "bobby", + "bobby", + parentCommentId + ); + } + +} \ No newline at end of file diff --git a/board/src/test/java/com/example/board/service/ArticleCommentServiceTest.java b/board/src/test/java/com/example/board/service/ArticleCommentServiceTest.java index 1b86381d..63e70d99 100644 --- a/board/src/test/java/com/example/board/service/ArticleCommentServiceTest.java +++ b/board/src/test/java/com/example/board/service/ArticleCommentServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import javax.persistence.EntityNotFoundException; import java.time.LocalDateTime; @@ -22,6 +23,7 @@ import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; import static org.mockito.BDDMockito.*; @DisplayName("비즈니스 로직 - 댓글") @@ -46,18 +48,25 @@ class ArticleCommentServiceTest { // given Long articleId = 1L; - ArticleComment expected = createArticleComment("content"); - - given(articleCommentRepository.findByArticle_Id(articleId)).willReturn(List.of(expected)); + ArticleComment expectedParentComment = createArticleComment(1L, "parent content"); + ArticleComment expectedChildComment = createArticleComment(2L, "child content"); + expectedChildComment.setParentCommentId(expectedParentComment.getId()); + given(articleCommentRepository.findByArticle_Id(articleId)).willReturn(List.of( + expectedParentComment, + expectedChildComment + )); // when List actual = sut.searchArticleComments(articleId); // then + assertThat(actual).hasSize(2); assertThat(actual) - .hasSize(1) - .first().hasFieldOrPropertyWithValue("content", expected.getContent()); - then(articleCommentRepository).should().findByArticle_Id(articleId); + .extracting("id", "articleId", "parentCommentId", "content") + .containsExactlyInAnyOrder( + tuple(1L, 1L, null, "parent content"), + tuple(2L, 1L, 1L, "child content") + ); } @DisplayName("댓글 정보를 입력하면, 댓글을 저장한다.") @@ -75,6 +84,7 @@ class ArticleCommentServiceTest { // Then then(articleRepository).should().getReferenceById(dto.articleId()); then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId()); + then(articleCommentRepository).should(never()).getReferenceById(anyLong()); then(articleCommentRepository).should().save(any(ArticleComment.class)); } @@ -94,38 +104,26 @@ class ArticleCommentServiceTest { then(articleCommentRepository).shouldHaveNoInteractions(); } - @DisplayName("댓글 정보를 입력하면, 댓글을 수정한다.") + @DisplayName("부모 댓글 ID와 댓글 정보를 입력하면, 대댓글을 저장한다.") @Test - void givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment() { + void givenParentCommentIdAndArticleCommentInfo_whenSaving_thenSavesChildComment() { // Given - String oldContent = "content"; - String updatedContent = "댓글"; - ArticleComment articleComment = createArticleComment(oldContent); - ArticleCommentDto dto = createArticleCommentDto(updatedContent); - given(articleCommentRepository.getReferenceById(dto.id())).willReturn(articleComment); + Long parentCommentId = 1L; + ArticleComment parent = createArticleComment(parentCommentId, "댓글"); + ArticleCommentDto child = createArticleCommentDto(parentCommentId, "대댓글"); + given(articleRepository.getReferenceById(child.articleId())).willReturn(createArticle()); + given(userAccountRepository.getReferenceById(child.userAccountDto().userId())).willReturn(createUserAccount()); + given(articleCommentRepository.getReferenceById(child.parentCommentId())).willReturn(parent); // When - sut.updateArticleComment(dto); + sut.saveArticleComment(child); // Then - assertThat(articleComment.getContent()) - .isNotEqualTo(oldContent) - .isEqualTo(updatedContent); - then(articleCommentRepository).should().getReferenceById(dto.id()); - } - - @DisplayName("없는 댓글 정보를 수정하려고 하면, 경고 로그를 찍고 아무 것도 안 한다.") - @Test - void givenNonexistentArticleComment_whenUpdatingArticleComment_thenLogsWarningAndDoesNothing() { - // Given - ArticleCommentDto dto = createArticleCommentDto("댓글"); - given(articleCommentRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class); - - // When - sut.updateArticleComment(dto); - - // Then - then(articleCommentRepository).should().getReferenceById(dto.id()); + assertThat(child.parentCommentId()).isNotNull(); + then(articleRepository).should().getReferenceById(child.articleId()); + then(userAccountRepository).should().getReferenceById(child.userAccountDto().userId()); + then(articleCommentRepository).should().getReferenceById(child.parentCommentId()); + then(articleCommentRepository).should(never()).save(any(ArticleComment.class)); } @DisplayName("댓글 ID를 입력하면, 댓글을 삭제한다.") @@ -143,12 +141,20 @@ class ArticleCommentServiceTest { then(articleCommentRepository).should().deleteByIdAndUserAccount_UserId(articleCommentId, userId); } - private ArticleCommentDto createArticleCommentDto(String content) { + return createArticleCommentDto(null, content); + } + + private ArticleCommentDto createArticleCommentDto(Long parentCommentId, String content) { + return createArticleCommentDto(1L, parentCommentId, content); + } + + private ArticleCommentDto createArticleCommentDto(Long id, Long parentCommentId, String content) { return ArticleCommentDto.of( - 1L, + id, 1L, createUserAccountDto(), + parentCommentId, content, LocalDateTime.now(), "bobby", @@ -171,12 +177,15 @@ class ArticleCommentServiceTest { ); } - private ArticleComment createArticleComment(String content) { - return ArticleComment.of( + private ArticleComment createArticleComment(Long id, String content) { + ArticleComment articleComment = ArticleComment.of( createArticle(), createUserAccount(), content ); + ReflectionTestUtils.setField(articleComment, "id", id); + + return articleComment; } private UserAccount createUserAccount() { @@ -195,6 +204,7 @@ class ArticleCommentServiceTest { "title", "content" ); + ReflectionTestUtils.setField(article, "id", 1L); article.addHashtags(Set.of(createHashtag(article))); return article;