#16 board: child comment - logic implementation

This commit is contained in:
haerong22
2023-01-23 02:51:45 +09:00
parent 6835f7da1d
commit 1ccb1e4db9
8 changed files with 324 additions and 50 deletions

View File

@@ -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(),

View File

@@ -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
);
}

View File

@@ -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<ArticleCommentResponse> 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<ArticleCommentResponse> 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;
}
}

View File

@@ -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<ArticleCommentResponse> organizeChildComments(Set<ArticleCommentDto> dtos) {
Map<Long, ArticleCommentResponse> 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)
)
));
}
}

View File

@@ -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());
}

View File

@@ -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));
}
}

View File

@@ -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<ArticleCommentDto> 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<ArticleCommentDto> 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<ArticleCommentDto> 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<ArticleCommentResponse> 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<ArticleCommentDto> 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
);
}
}

View File

@@ -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<ArticleCommentDto> 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;