From d89e933151fcb339c4581aac94f05342edceb3af Mon Sep 17 00:00:00 2001 From: Lee Min Woo Date: Sun, 15 Jan 2023 16:05:57 +0900 Subject: [PATCH] =?UTF-8?q?#69=20-=20=EB=8C=80=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부모/자식 관계를 만들면서 entity <-> dto 간 변환 로직, 댓글 서비스에서 댓글과 대댓글을 저장하고 삭제하는 방법, 댓글과 대댓글을 db 데이터로부터 변환할 때 서로 계층 구조를 만들고 각각 정렬하는 과정을 구현 --- .../projectboard/dto/ArticleCommentDto.java | 13 +- .../dto/request/ArticleCommentRequest.java | 13 +- .../dto/response/ArticleCommentResponse.java | 25 ++- .../response/ArticleWithCommentsResponse.java | 32 +++- .../service/ArticleCommentService.java | 9 +- .../ArticleWithCommentsResponseTest.java | 181 ++++++++++++++++++ .../service/ArticleCommentServiceTest.java | 79 ++++---- 7 files changed, 304 insertions(+), 48 deletions(-) create mode 100644 src/test/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponseTest.java diff --git a/src/main/java/com/fastcampus/projectboard/dto/ArticleCommentDto.java b/src/main/java/com/fastcampus/projectboard/dto/ArticleCommentDto.java index 34e4f46..000b44d 100644 --- a/src/main/java/com/fastcampus/projectboard/dto/ArticleCommentDto.java +++ b/src/main/java/com/fastcampus/projectboard/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,10 +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) { @@ -29,6 +35,7 @@ public static ArticleCommentDto from(ArticleComment entity) { entity.getId(), entity.getArticle().getId(), UserAccountDto.from(entity.getUserAccount()), + entity.getParentCommentId(), entity.getContent(), entity.getCreatedAt(), entity.getCreatedBy(), diff --git a/src/main/java/com/fastcampus/projectboard/dto/request/ArticleCommentRequest.java b/src/main/java/com/fastcampus/projectboard/dto/request/ArticleCommentRequest.java index 301f2b0..a657e59 100644 --- a/src/main/java/com/fastcampus/projectboard/dto/request/ArticleCommentRequest.java +++ b/src/main/java/com/fastcampus/projectboard/dto/request/ArticleCommentRequest.java @@ -3,16 +3,25 @@ import com.fastcampus.projectboard.dto.ArticleCommentDto; import com.fastcampus.projectboard.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/src/main/java/com/fastcampus/projectboard/dto/response/ArticleCommentResponse.java b/src/main/java/com/fastcampus/projectboard/dto/response/ArticleCommentResponse.java index 29ef9d6..fb2d0c1 100644 --- a/src/main/java/com/fastcampus/projectboard/dto/response/ArticleCommentResponse.java +++ b/src/main/java/com/fastcampus/projectboard/dto/response/ArticleCommentResponse.java @@ -3,6 +3,9 @@ import com.fastcampus.projectboard.dto.ArticleCommentDto; import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.Set; +import java.util.TreeSet; public record ArticleCommentResponse( Long id, @@ -10,11 +13,20 @@ public record ArticleCommentResponse( LocalDateTime createdAt, String email, String nickname, - String userId + String userId, + Long parentCommentId, + Set childComments ) { 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) { @@ -23,14 +35,19 @@ public static ArticleCommentResponse from(ArticleCommentDto dto) { 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/src/main/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponse.java b/src/main/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponse.java index 9eeb436..16820e0 100644 --- a/src/main/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponse.java +++ b/src/main/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponse.java @@ -1,11 +1,12 @@ package com.fastcampus.projectboard.dto.response; +import com.fastcampus.projectboard.dto.ArticleCommentDto; import com.fastcampus.projectboard.dto.ArticleWithCommentsDto; import com.fastcampus.projectboard.dto.HashtagDto; import java.time.LocalDateTime; -import java.util.LinkedHashSet; -import java.util.Set; +import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; public record ArticleWithCommentsResponse( @@ -42,10 +43,31 @@ public static ArticleWithCommentsResponse from(ArticleWithCommentsDto dto) { 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/src/main/java/com/fastcampus/projectboard/service/ArticleCommentService.java b/src/main/java/com/fastcampus/projectboard/service/ArticleCommentService.java index 28cdf80..b7a9240 100644 --- a/src/main/java/com/fastcampus/projectboard/service/ArticleCommentService.java +++ b/src/main/java/com/fastcampus/projectboard/service/ArticleCommentService.java @@ -37,7 +37,14 @@ public void saveArticleComment(ArticleCommentDto dto) { 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:{}", dto); } diff --git a/src/test/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponseTest.java b/src/test/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponseTest.java new file mode 100644 index 0000000..4396681 --- /dev/null +++ b/src/test/java/com/fastcampus/projectboard/dto/response/ArticleWithCommentsResponseTest.java @@ -0,0 +1,181 @@ +package com.fastcampus.projectboard.dto.response; + +import com.fastcampus.projectboard.dto.ArticleCommentDto; +import com.fastcampus.projectboard.dto.ArticleWithCommentsDto; +import com.fastcampus.projectboard.dto.HashtagDto; +import com.fastcampus.projectboard.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(), + "uno", + LocalDateTime.now(), + "uno" + ); + } + + private UserAccountDto createUserAccountDto() { + return UserAccountDto.of( + "uno", + "password", + "uno@mail.com", + "Uno", + "This is memo", + LocalDateTime.now(), + "uno", + LocalDateTime.now(), + "uno" + ); + } + + private ArticleCommentDto createArticleCommentDto(Long id, Long parentCommentId, LocalDateTime createdAt) { + return ArticleCommentDto.of( + id, + 1L, + createUserAccountDto(), + parentCommentId, + "test comment " + id, + createdAt, + "uno", + createdAt, + "uno" + ); + } + + private ArticleCommentResponse createArticleCommentResponse(Long id, Long parentCommentId, LocalDateTime createdAt) { + return ArticleCommentResponse.of( + id, + "test comment " + id, + createdAt, + "uno@mail.com", + "Uno", + "uno", + parentCommentId + ); + } + +} \ No newline at end of file diff --git a/src/test/java/com/fastcampus/projectboard/service/ArticleCommentServiceTest.java b/src/test/java/com/fastcampus/projectboard/service/ArticleCommentServiceTest.java index bf404eb..1cac239 100644 --- a/src/test/java/com/fastcampus/projectboard/service/ArticleCommentServiceTest.java +++ b/src/test/java/com/fastcampus/projectboard/service/ArticleCommentServiceTest.java @@ -15,6 +15,7 @@ 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.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.*; @@ -40,16 +42,25 @@ class ArticleCommentServiceTest { void givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments() { // 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()); + .extracting("id", "articleId", "parentCommentId", "content") + .containsExactlyInAnyOrder( + tuple(1L, 1L, null, "parent content"), + tuple(2L, 1L, 1L, "child content") + ); then(articleCommentRepository).should().findByArticle_Id(articleId); } @@ -68,6 +79,7 @@ void givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment() // 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)); } @@ -87,38 +99,26 @@ void givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNo 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를 입력하면, 댓글을 삭제한다.") @@ -138,10 +138,19 @@ void givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment( 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(), "uno", @@ -164,12 +173,15 @@ private UserAccountDto createUserAccountDto() { ); } - 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() { @@ -188,6 +200,7 @@ private Article createArticle() { "title", "content" ); + ReflectionTestUtils.setField(article, "id", 1L); article.addHashtags(Set.of(createHashtag(article))); return article;