diff --git a/src/main/java/com/catcher/app/AppApplication.java b/src/main/java/com/catcher/app/AppApplication.java index 630bfc2..1547717 100644 --- a/src/main/java/com/catcher/app/AppApplication.java +++ b/src/main/java/com/catcher/app/AppApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication @ComponentScan(basePackages = {"com.catcher.core", "com.catcher.resource"}) +@EnableJpaRepositories(basePackages = {"com.catcher.datasource"}) +@EntityScan(basePackages = {"com.catcher.core.domain.entity"}) public class AppApplication { public static void main(String[] args) { diff --git a/src/main/java/com/catcher/core/CommandExecutor.java b/src/main/java/com/catcher/core/CommandExecutor.java deleted file mode 100644 index 0d7f34c..0000000 --- a/src/main/java/com/catcher/core/CommandExecutor.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.catcher.core; - -import com.catcher.core.domain.command.Command; - -public interface CommandExecutor { - T run(Command command); -} diff --git a/src/main/java/com/catcher/core/domain/command/Command.java b/src/main/java/com/catcher/core/domain/command/Command.java index 7aa1058..a7498a2 100644 --- a/src/main/java/com/catcher/core/domain/command/Command.java +++ b/src/main/java/com/catcher/core/domain/command/Command.java @@ -1,4 +1,5 @@ package com.catcher.core.domain.command; -public interface Command { +public interface Command { + T execute(); } diff --git a/src/main/java/com/catcher/core/domain/command/CommandExecutor.java b/src/main/java/com/catcher/core/domain/command/CommandExecutor.java new file mode 100644 index 0000000..52290ac --- /dev/null +++ b/src/main/java/com/catcher/core/domain/command/CommandExecutor.java @@ -0,0 +1,5 @@ +package com.catcher.core.domain.command; + +public interface CommandExecutor { + T run(Command command); +} diff --git a/src/main/java/com/catcher/core/domain/command/CommentCommandExecutor.java b/src/main/java/com/catcher/core/domain/command/CommentCommandExecutor.java new file mode 100644 index 0000000..8c7b382 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/command/CommentCommandExecutor.java @@ -0,0 +1,13 @@ +package com.catcher.core.domain.command; + +import org.springframework.stereotype.Component; + +@Component +public class CommentCommandExecutor implements CommandExecutor { + + @Override + public T run(Command command) { + return command.execute(); + } + +} diff --git a/src/main/java/com/catcher/core/domain/command/GetCommentCommand.java b/src/main/java/com/catcher/core/domain/command/GetCommentCommand.java new file mode 100644 index 0000000..7737247 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/command/GetCommentCommand.java @@ -0,0 +1,19 @@ +package com.catcher.core.domain.command; + +import com.catcher.core.domain.entity.Comment; +import com.catcher.core.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@RequiredArgsConstructor +public class GetCommentCommand implements Command> { + + private final CommentService commentService; + + private final Pageable pageable; + @Override + public Page execute() { + return commentService.findByParentIsNull(pageable); + } +} diff --git a/src/main/java/com/catcher/core/domain/command/PostCommentCommand.java b/src/main/java/com/catcher/core/domain/command/PostCommentCommand.java new file mode 100644 index 0000000..1b30095 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/command/PostCommentCommand.java @@ -0,0 +1,21 @@ +package com.catcher.core.domain.command; + +import com.catcher.core.domain.request.PostCommentRequest; +import com.catcher.core.service.CommentService; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PostCommentCommand implements Command { + + private final CommentService commentService; + + private final PostCommentRequest postCommentRequest; + + @Override + public Void execute() { + commentService.saveSingleComment(postCommentRequest.getUserId(), postCommentRequest.getContents()); + + return null; + } + +} diff --git a/src/main/java/com/catcher/core/domain/command/PostCommentReplyCommand.java b/src/main/java/com/catcher/core/domain/command/PostCommentReplyCommand.java new file mode 100644 index 0000000..7e13084 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/command/PostCommentReplyCommand.java @@ -0,0 +1,25 @@ +package com.catcher.core.domain.command; + +import com.catcher.core.domain.request.PostCommentReplyRequest; +import com.catcher.core.service.CommentService; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PostCommentReplyCommand implements Command { + + private final CommentService commentService; + + private final PostCommentReplyRequest postCommentReplyRequest; + + @Override + public Void execute() { + commentService.saveSingleReply( + postCommentReplyRequest.getParentId(), + postCommentReplyRequest.getUserId(), + postCommentReplyRequest.getContents() + ); + + return null; + } + +} diff --git a/src/main/java/com/catcher/core/domain/entity/Comment.java b/src/main/java/com/catcher/core/domain/entity/Comment.java new file mode 100644 index 0000000..8d6bd46 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/entity/Comment.java @@ -0,0 +1,33 @@ +package com.catcher.core.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "parent_id") + private Comment parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @Builder.Default + private List replies = new ArrayList<>(); + + private Long userId; + + private String contents; +} diff --git a/src/main/java/com/catcher/core/domain/request/PostCommentReplyRequest.java b/src/main/java/com/catcher/core/domain/request/PostCommentReplyRequest.java new file mode 100644 index 0000000..f28d1a8 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/request/PostCommentReplyRequest.java @@ -0,0 +1,19 @@ +package com.catcher.core.domain.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder // TODO: 테스트를 위해서만 필요한 경우? +@NoArgsConstructor +@AllArgsConstructor +public class PostCommentReplyRequest { + + private Long userId; + + private Long parentId; + + private String contents; +} diff --git a/src/main/java/com/catcher/core/domain/request/PostCommentRequest.java b/src/main/java/com/catcher/core/domain/request/PostCommentRequest.java new file mode 100644 index 0000000..27930cb --- /dev/null +++ b/src/main/java/com/catcher/core/domain/request/PostCommentRequest.java @@ -0,0 +1,17 @@ +package com.catcher.core.domain.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder // TODO: 테스트를 위해서만 필요한 경우? +@NoArgsConstructor +@AllArgsConstructor +public class PostCommentRequest { + + private Long userId; + + private String contents; +} diff --git a/src/main/java/com/catcher/core/domain/response/GetCommentsByPageResponse.java b/src/main/java/com/catcher/core/domain/response/GetCommentsByPageResponse.java new file mode 100644 index 0000000..5fed208 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/response/GetCommentsByPageResponse.java @@ -0,0 +1,43 @@ +package com.catcher.core.domain.response; + +import com.catcher.core.domain.entity.Comment; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +public class GetCommentsByPageResponse { + + private Long id; + + private String contents; + + private List childComments; + + public static List createGetCommentsByPageResponseList(Page commentPage) { + return commentPage + .stream() + .map(GetCommentsByPageResponse::buildRecursiveCommentResponse) + .collect(Collectors.toList()); + } + + private static GetCommentsByPageResponse buildRecursiveCommentResponse(Comment comment) { + GetCommentsByPageResponse response = GetCommentsByPageResponse + .builder() + .id(comment.getId()) + .contents(comment.getContents()) + .childComments(new ArrayList<>()) + .build(); + + for (Comment reply : comment.getReplies()) { + response.getChildComments().add(buildRecursiveCommentResponse(reply)); + } + + return response; + } +} diff --git a/src/main/java/com/catcher/core/service/CommentService.java b/src/main/java/com/catcher/core/service/CommentService.java new file mode 100644 index 0000000..716b922 --- /dev/null +++ b/src/main/java/com/catcher/core/service/CommentService.java @@ -0,0 +1,48 @@ +package com.catcher.core.service; + +import com.catcher.core.domain.entity.Comment; +import com.catcher.datasource.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + + @Transactional + public Page findByParentIsNull(final Pageable pageable) { + return commentRepository.findByParentIsNull(pageable); + } + + @Transactional + public Comment saveSingleComment(final Long userId, final String contents) { + return commentRepository.save(Comment + .builder() + .userId(userId) + .contents(contents) + .build()); + } + + @Transactional + public Comment saveSingleReply(Long parentId, Long userId, String contents) { + final Comment parentComment = commentRepository + .findById(parentId) + .orElseThrow(); //TODO: fill custom exception + + final Comment reply = Comment + .builder() + .userId(userId) + .parent(parentComment) + .contents(contents) + .build(); + + parentComment.getReplies().add(reply); + + return reply; + } +} diff --git a/src/main/java/com/catcher/datasource/CommentRepository.java b/src/main/java/com/catcher/datasource/CommentRepository.java new file mode 100644 index 0000000..e9654c0 --- /dev/null +++ b/src/main/java/com/catcher/datasource/CommentRepository.java @@ -0,0 +1,14 @@ +package com.catcher.datasource; + +import com.catcher.core.domain.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + // TODO: N+1 join +// @Query(value = "SELECT DISTINCT c FROM Comment c LEFT OUTER JOIN FETCH c.replies WHERE c.parent IS NULL") + Page findByParentIsNull(Pageable pageable); + +} diff --git a/src/main/java/com/catcher/resource/CommentAPiController.java b/src/main/java/com/catcher/resource/CommentAPiController.java new file mode 100644 index 0000000..35dede1 --- /dev/null +++ b/src/main/java/com/catcher/resource/CommentAPiController.java @@ -0,0 +1,45 @@ +package com.catcher.resource; + +import com.catcher.core.domain.command.*; +import com.catcher.core.domain.entity.Comment; +import com.catcher.core.domain.request.PostCommentReplyRequest; +import com.catcher.core.domain.request.PostCommentRequest; +import com.catcher.core.domain.response.GetCommentsByPageResponse; +import com.catcher.core.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/comment") +public class CommentAPiController { + + private final CommentService commentService; + + private final CommentCommandExecutor commandExecutor; + + @PostMapping + public void postComment(@RequestBody PostCommentRequest postCommentRequest) { + Command command = new PostCommentCommand(commentService, postCommentRequest); + commandExecutor.run(command); + } + + @PostMapping("/reply") + public void replyComment(@RequestBody PostCommentReplyRequest postCommentReplyRequest) { + Command command = new PostCommentReplyCommand(commentService, postCommentReplyRequest); + commandExecutor.run(command); + } + + @GetMapping + public List getComments(@PageableDefault(size = 20, sort = {"id"}) Pageable pageable) { + Command> command = new GetCommentCommand(commentService, pageable); + Page commentPage = commandExecutor.run(command); + + return GetCommentsByPageResponse.createGetCommentsByPageResponseList(commentPage); + } +} diff --git a/src/test/java/com/catcher/app/AppApplicationTests.java b/src/test/java/com/catcher/app/AppApplicationTests.java index 000c821..4a6e93c 100644 --- a/src/test/java/com/catcher/app/AppApplicationTests.java +++ b/src/test/java/com/catcher/app/AppApplicationTests.java @@ -1,11 +1,15 @@ package com.catcher.app; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootTest @ComponentScan(basePackages = {"com.catcher.resource"}) +@EnableJpaRepositories(basePackages = {"com.catcher.datasource"}) +@EntityScan(basePackages = {"com.catcher.core.domain.entity"}) class AppApplicationTests { @Test diff --git a/src/test/java/com/catcher/datasource/TestCommentRepository.java b/src/test/java/com/catcher/datasource/TestCommentRepository.java new file mode 100644 index 0000000..48b36fe --- /dev/null +++ b/src/test/java/com/catcher/datasource/TestCommentRepository.java @@ -0,0 +1,11 @@ +package com.catcher.datasource; + +import com.catcher.core.domain.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +// TODO: 프로덕션 코드에는 없는 기능이 필요한 경우? +public interface TestCommentRepository extends JpaRepository { + + Comment findFirstByUserIdAndContentsOrderByIdDesc(Long userId, String contents); + +} diff --git a/src/test/java/com/catcher/resource/PostCommentControllerTest.java b/src/test/java/com/catcher/resource/PostCommentControllerTest.java new file mode 100644 index 0000000..82244b9 --- /dev/null +++ b/src/test/java/com/catcher/resource/PostCommentControllerTest.java @@ -0,0 +1,178 @@ +package com.catcher.resource; + +import com.catcher.app.AppApplication; +import com.catcher.core.domain.entity.Comment; +import com.catcher.core.domain.request.PostCommentReplyRequest; +import com.catcher.core.domain.request.PostCommentRequest; +import com.catcher.core.domain.response.GetCommentsByPageResponse; +import com.catcher.core.service.CommentService; +import com.catcher.datasource.TestCommentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = AppApplication.class) +@AutoConfigureMockMvc +public class PostCommentControllerTest { + + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestCommentRepository commentRepository; + + @Autowired + private CommentService commentService; + + @BeforeEach + public void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @Test + @Transactional + public void testPostComment() throws Exception { + + // given + Long userId = 1L; + String contents = "댓글 작성 테스트"; + PostCommentRequest postCommentRequest = PostCommentRequest + .builder() + .userId(userId) + .contents(contents) + .build(); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/comment") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(postCommentRequest)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(print()); + + // then + Comment savedComment = commentRepository.findFirstByUserIdAndContentsOrderByIdDesc(userId, contents); + assertNotNull(savedComment); + assertEquals(contents, savedComment.getContents()); + } + + @Test + @Transactional + public void testPostCommentReply() throws Exception { + + // given + Long userId = 2L; + String contents = "대댓글 작성 테스트"; + Comment parentComment = commentRepository.save(Comment + .builder() + .userId(1L) + .contents("댓글 작성 테스트") + .build()); + PostCommentReplyRequest postCommentReplyRequest = PostCommentReplyRequest + .builder() + .userId(userId) + .parentId(parentComment.getId()) + .contents(contents) + .build(); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/comment/reply") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(postCommentReplyRequest)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(print()); + + // then + Comment reply = commentRepository.findFirstByUserIdAndContentsOrderByIdDesc(userId, contents); + assertNotNull(reply); + assertEquals(contents, reply.getContents()); + assertEquals(parentComment.getId(), reply.getParent().getId()); + } + + @Test + @Transactional + public void 댓글목록조회_테스트_DB조회() { + + // given + Comment parentComment1 = commentService.saveSingleComment(1L, "댓글 작성 테스트1"); + + Comment parentComment2 = commentService.saveSingleComment(2L, "댓글 작성 테스트2"); + + Comment childComment = commentService.saveSingleReply(parentComment1.getId(), 3L, "대댓글 작성 테스트"); + + // when + final var commentList = commentService + .findByParentIsNull(PageRequest.of(0, 2, Sort.Direction.DESC, "id")) + .stream().toList(); + + Comment result1 = commentList.get(0); + Comment result2 = commentList.get(1); + + // then + assertEquals(result1.getId(), parentComment2.getId()); // 내림차순 정렬 확인 + assertEquals(result2.getReplies().get(0).getId(), childComment.getId()); // 대댓글 조회 확인 + } + + @Test + public void 댓글목록조회_테스트_VO생성() { + + // given - 댓글 2개, 1번 댓글에 대댓글 1개, 해당 대댓글에 대대댓글 1개 추가 후 JPA 영속 상태 mocking + Comment parentComment1 = new Comment(1L, null, new ArrayList<>(), 1L, "댓글 작성 테스트1"); + Comment parentComment2 = new Comment(2L, null, new ArrayList<>(), 1L, "댓글 작성 테스트2"); + Comment childComment = new Comment(3L, parentComment1, new ArrayList<>(), 2L, "대댓글 작성 테스트"); + Comment nestedChildComment = new Comment(4L, childComment, new ArrayList<>(), 3L, "대대댓글 작성 테스트"); + parentComment1.getReplies().add(childComment); + childComment.getReplies().add(nestedChildComment); + + Page parentCommentsPage = new PageImpl<>(List.of(parentComment1, parentComment2)); + + // when + List responseVoList = GetCommentsByPageResponse.createGetCommentsByPageResponseList(parentCommentsPage); + GetCommentsByPageResponse result1 = responseVoList.get(0); + GetCommentsByPageResponse result2 = responseVoList.get(1); + GetCommentsByPageResponse childResult = result1.getChildComments().get(0); + GetCommentsByPageResponse nestedChildResult = childResult.getChildComments().get(0); + + // then + assertEquals(result1.getId(), parentComment1.getId()); + assertEquals(result2.getId(), parentComment2.getId()); + assertEquals(result2.getChildComments().size(), 0); + assertEquals(childResult.getId(), childComment.getId()); + assertEquals(nestedChildResult.getId(), nestedChildComment.getId()); + assertEquals(nestedChildResult.getChildComments().size(), 0); + + } + + +}