diff --git a/.gradle/8.14.3/checksums/checksums.lock b/.gradle/8.14.3/checksums/checksums.lock new file mode 100644 index 0000000..461b61c Binary files /dev/null and b/.gradle/8.14.3/checksums/checksums.lock differ diff --git a/.gradle/8.14.3/executionHistory/executionHistory.lock b/.gradle/8.14.3/executionHistory/executionHistory.lock new file mode 100644 index 0000000..47ab024 Binary files /dev/null and b/.gradle/8.14.3/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.14.3/fileChanges/last-build.bin b/.gradle/8.14.3/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.14.3/fileChanges/last-build.bin differ diff --git a/.gradle/8.14.3/fileHashes/fileHashes.lock b/.gradle/8.14.3/fileHashes/fileHashes.lock new file mode 100644 index 0000000..f3408eb Binary files /dev/null and b/.gradle/8.14.3/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.14.3/gc.properties b/.gradle/8.14.3/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..d8abf00 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..0866dc3 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Aug 26 15:38:41 KST 2025 +gradle.version=8.14.3 diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..d44d4d4 --- /dev/null +++ b/HELP.md @@ -0,0 +1,47 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin/packaging-oci-image.html) +* [Spring Boot Testcontainers support](https://docs.spring.io/spring-boot/3.5.5/reference/testing/testcontainers.html#testing.testcontainers) +* [Testcontainers MySQL Module Reference Guide](https://java.testcontainers.org/modules/databases/mysql/) +* [Spring Web](https://docs.spring.io/spring-boot/3.5.5/reference/web/servlet.html) +* [Validation](https://docs.spring.io/spring-boot/3.5.5/reference/io/validation.html) +* [Spring Security](https://docs.spring.io/spring-boot/3.5.5/reference/web/spring-security.html) +* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.5/reference/data/sql.html#data.sql.jpa-and-spring-data) +* [Flyway Migration](https://docs.spring.io/spring-boot/3.5.5/how-to/data-initialization.html#howto.data-initialization.migration-tool.flyway) +* [Spring Boot Actuator](https://docs.spring.io/spring-boot/3.5.5/reference/actuator/index.html) +* [Testcontainers](https://java.testcontainers.org/) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Validation](https://spring.io/guides/gs/validating-form-input/) +* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) +* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) +* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) +* [Building a RESTful Web Service with Spring Boot Actuator](https://spring.io/guides/gs/actuator-service/) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + +### Testcontainers support + +This project uses [Testcontainers at development time](https://docs.spring.io/spring-boot/3.5.5/reference/features/dev-services.html#features.dev-services.testcontainers). + +Testcontainers has been configured to use the following Docker images: + +* [`mysql:latest`](https://hub.docker.com/_/mysql) + +Please review the tags of the used images and set them to the same as you're running in production. + diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/src/main/java/com/issueDive/controller/CommentController.java b/src/main/java/com/issueDive/controller/CommentController.java new file mode 100644 index 0000000..b5a49b8 --- /dev/null +++ b/src/main/java/com/issueDive/controller/CommentController.java @@ -0,0 +1,50 @@ +package com.issueDive.controller; + +import com.issueDive.dto.*; +import com.issueDive.service.CommentService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/issues/{issueId}/comments") +public class CommentController { + private final CommentService commentService; + + @GetMapping + public ResponseEntity>> getComment(@PathVariable Long issueId){ + List tree = commentService.getTreeByIssue(issueId); + return ResponseEntity.ok(ApiResponse.ok(tree)); + } + + @PostMapping + public ResponseEntity> createComment(@PathVariable Long issueId, @RequestBody @Valid CreateCommentRequest request + ,@RequestHeader("X-USER-ID") Long userId){ + + CommentResponse created = commentService.createComment(issueId, request, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(created)); + } + + @PatchMapping("/{commentId}") + public ResponseEntity> updateComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestBody @Valid UpdateCommentRequest request, @RequestHeader("X-USER-ID") Long userId){ + CommentResponse updated = commentService.updateComment(issueId, commentId, request, userId); + return ResponseEntity.ok(ApiResponse.ok(updated)); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestHeader("X-USER-ID") Long userId){ + commentService.deleteComment(issueId, commentId, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/count") + public ResponseEntity> countComment(@PathVariable Long issueId){ + CountCommentResponse response = commentService.countByIssue(issueId); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/issueDive/dto/CommentResponse.java b/src/main/java/com/issueDive/dto/CommentResponse.java new file mode 100644 index 0000000..ef9a1ea --- /dev/null +++ b/src/main/java/com/issueDive/dto/CommentResponse.java @@ -0,0 +1,81 @@ +package com.issueDive.dto; + +import com.issueDive.entity.Comment; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CommentResponse { + + private Long id; + private Long issueId; + private Long userId; + private String author; + private String description; + private Long parentId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @Builder.Default + private List children = new ArrayList<>(); + + // 방어적 추가 헬퍼 + public void addChild(CommentResponse child) { + if (children == null) children = new ArrayList<>(); + children.add(child); + } + + // 널-세이프 getter + public List getChildren() { + if (children == null) children = new ArrayList<>(); + return children; + } + + public static CommentResponse from(Comment comment){ + return CommentResponse.builder() + .id(comment.getId()) + .issueId(comment.getIssue().getId()) + .userId(comment.getUser().getId()) + .author(comment.getUser().getUsername()) + .description(comment.getDescription()) + .parentId(comment.getParent() != null ? comment.getParent().getId() : null) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .build(); + } + + public static List fromAllToTree(List comments){ + Map map = new LinkedHashMap<>(); + List roots = new ArrayList<>(); + + // 1) 엔티티 → DTO 변환 & 인덱싱 + for (Comment c : comments) { + map.put(c.getId(), from(c)); + } + + // 2) 부모-자식 연결 + for (Comment c : comments) { + Long pId = (c.getParent() != null) ? c.getParent().getId() : null; + CommentResponse dto = map.get(c.getId()); + + if (pId == null) { + roots.add(dto); + } else { + CommentResponse parent = map.get(pId); + if (parent != null) { + parent.addChild(dto); + } else { + // 부모 누락 방어: 루트에 승격(정책에 맞게 조정 가능) + roots.add(dto); + } + } + } + return roots; + } +} diff --git a/src/main/java/com/issueDive/dto/CountCommentResponse.java b/src/main/java/com/issueDive/dto/CountCommentResponse.java new file mode 100644 index 0000000..dfabc8d --- /dev/null +++ b/src/main/java/com/issueDive/dto/CountCommentResponse.java @@ -0,0 +1,13 @@ +package com.issueDive.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class CountCommentResponse { + private Long issueId; + private Long count; +} diff --git a/src/main/java/com/issueDive/dto/CreateCommentRequest.java b/src/main/java/com/issueDive/dto/CreateCommentRequest.java new file mode 100644 index 0000000..ad2f21c --- /dev/null +++ b/src/main/java/com/issueDive/dto/CreateCommentRequest.java @@ -0,0 +1,12 @@ +package com.issueDive.dto; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateCommentRequest { + @NotBlank + private String description; + private Long parentId; +} diff --git a/src/main/java/com/issueDive/dto/UpdateCommentRequest.java b/src/main/java/com/issueDive/dto/UpdateCommentRequest.java new file mode 100644 index 0000000..6bdad90 --- /dev/null +++ b/src/main/java/com/issueDive/dto/UpdateCommentRequest.java @@ -0,0 +1,13 @@ +package com.issueDive.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UpdateCommentRequest { + + @NotBlank + private String description; +} diff --git a/src/main/java/com/issueDive/entity/Comment.java b/src/main/java/com/issueDive/entity/Comment.java new file mode 100644 index 0000000..89c511e --- /dev/null +++ b/src/main/java/com/issueDive/entity/Comment.java @@ -0,0 +1,52 @@ +package com.issueDive.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "comments") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "issue_id", nullable = false) + private Issue issue; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Lob + @Column(nullable = false) + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @Builder.Default + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + private List children = new ArrayList<>(); + + @Column(name = "created_at", updatable = false, insertable = false, + columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + private LocalDateTime createdAt; + + @Column(name = "updated_at", insertable = false, + columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") + private LocalDateTime updatedAt; + + public void changeDescription(String description) { this.description = description; } +} diff --git a/src/main/java/com/issueDive/exception/CommentNotFoundException.java b/src/main/java/com/issueDive/exception/CommentNotFoundException.java new file mode 100644 index 0000000..2a5975b --- /dev/null +++ b/src/main/java/com/issueDive/exception/CommentNotFoundException.java @@ -0,0 +1,12 @@ +package com.issueDive.exception; + +public class CommentNotFoundException extends RuntimeException{ + + public CommentNotFoundException(String message) { + super(message); + } + + public CommentNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/issueDive/exception/ErrorCode.java b/src/main/java/com/issueDive/exception/ErrorCode.java index 14ed9ef..a010f6a 100644 --- a/src/main/java/com/issueDive/exception/ErrorCode.java +++ b/src/main/java/com/issueDive/exception/ErrorCode.java @@ -1,7 +1,7 @@ package com.issueDive.exception; public enum ErrorCode { - ValidationError, InvalidQueryParam, Unauthorized, Forbidden, InternalServerError, + ValidationError, InvalidQueryParam, Unauthorized, Forbidden, BadRequest, InternalServerError, IssueNotFound, InvalidStatus, LabelNotFound, IssueLabelNotFound, DuplicateLabel, diff --git a/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java b/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java index ee5a610..bbf0c76 100644 --- a/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/issueDive/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.issueDive.dto.ErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -10,7 +11,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFound(NotFoundException e) { + public ResponseEntity handleLabelNotFound(NotFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ErrorResponse.of(ErrorCode.IssueNotFound, e.getMessage())); } @@ -32,4 +33,34 @@ public ResponseEntity handleLabelNotFound(LabelNotFoundException return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ErrorResponse.of(ErrorCode.LabelNotFound, e.getMessage())); } + + @ExceptionHandler(CommentNotFoundException.class) + public ResponseEntity handleCommentNotFound(CommentNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of(ErrorCode.CommentNotFound, e.getMessage())); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFound(UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of(ErrorCode.UserNotFound, e.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage())); + } + + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleForbidden(SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ErrorResponse.of(ErrorCode.Forbidden, e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage())); + } } diff --git a/src/main/java/com/issueDive/exception/UserNotFoundException.java b/src/main/java/com/issueDive/exception/UserNotFoundException.java new file mode 100644 index 0000000..caffd05 --- /dev/null +++ b/src/main/java/com/issueDive/exception/UserNotFoundException.java @@ -0,0 +1,10 @@ +package com.issueDive.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } + public UserNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/issueDive/repository/CommentRepository.java b/src/main/java/com/issueDive/repository/CommentRepository.java new file mode 100644 index 0000000..fd3d75e --- /dev/null +++ b/src/main/java/com/issueDive/repository/CommentRepository.java @@ -0,0 +1,17 @@ +package com.issueDive.repository; +import com.issueDive.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.issue.id = :issueId") + List findAllByIssueIdWithUser(@Param("issueId") Long issueId); + + long countByIssueId(Long issueId); + + +} diff --git a/src/main/java/com/issueDive/service/CommentService.java b/src/main/java/com/issueDive/service/CommentService.java new file mode 100644 index 0000000..6664588 --- /dev/null +++ b/src/main/java/com/issueDive/service/CommentService.java @@ -0,0 +1,109 @@ +package com.issueDive.service; + +import com.issueDive.dto.CommentResponse; +import com.issueDive.dto.*; +import com.issueDive.entity.Comment; +import com.issueDive.entity.Issue; +import com.issueDive.entity.User; +import com.issueDive.repository.CommentRepository; +import com.issueDive.repository.IssueRepository; +import com.issueDive.repository.UserRepository; +import com.issueDive.exception.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final IssueRepository issueRepository; + + + @Transactional + public CommentResponse createComment(Long issueId, CreateCommentRequest request, Long userId){ + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + Issue issue = issueRepository.findById(issueId) + .orElseThrow(() -> new NotFoundException("이슈를 찾을 수 없습니다.")); + + Comment.CommentBuilder commentBuilder = Comment.builder() + .description(request.getDescription()) + .user(user) + .issue(issue); + + if(request.getParentId() != null){ + Comment parent = commentRepository.findById(request.getParentId()) + .orElseThrow(() -> new CommentNotFoundException("부모 댓글을 찾을 수 없습니다.")); + + if(!parent.getIssue().getId().equals(issueId)){ + throw new IllegalArgumentException("부모 댓글이 다른 이슈에 소속되어 있습니다."); + } + + commentBuilder.parent(parent); + } + Comment comment = commentBuilder.build(); + + Comment saved = commentRepository.save(comment); + return CommentResponse.from(saved); + } + + @Transactional(readOnly = true) + public List getTreeByIssue(Long issueId){ + issueRepository.findById(issueId) + .orElseThrow(() -> new NotFoundException("이슈를 찾을 수 없습니다.")); + + List all = commentRepository.findAllByIssueIdWithUser(issueId); + return CommentResponse.fromAllToTree(all); + } + + @Transactional + public CommentResponse updateComment(Long issueId, Long id, UpdateCommentRequest request, Long userId){ + Comment comment = commentRepository.findById(id) + .orElseThrow(() -> new CommentNotFoundException("댓글을 찾을 수 없습니다.")); + issueRepository.findById(issueId) + .orElseThrow(() -> new NotFoundException("이슈를 찾을 수 없습니다.")); + if (!Objects.equals(comment.getIssue().getId(), issueId)) { + throw new IllegalArgumentException("요청한 이슈와 댓글의 소속이 일치하지 않습니다."); + } + + // 권한 검증 (403) + if (!Objects.equals(comment.getUser().getId(), userId)) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + + comment.changeDescription(request.getDescription()); + return CommentResponse.from(comment); + } + + @Transactional + public void deleteComment(Long issueId, Long id, Long userId){ + Comment comment = commentRepository.findById(id) + .orElseThrow(() -> new CommentNotFoundException("댓글을 찾을 수 없습니다.")); + issueRepository.findById(issueId) + .orElseThrow(() -> new NotFoundException("이슈를 찾을 수 없습니다.")); + + // 이슈-댓글 소속 검증 (400) + if (!Objects.equals(comment.getIssue().getId(), issueId)) { + throw new IllegalArgumentException("요청한 이슈와 댓글의 소속이 일치하지 않습니다."); + } + + // 권한 검증 (403) + if (!Objects.equals(comment.getUser().getId(), userId)) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + commentRepository.delete(comment); + } + + @Transactional(readOnly = true) + public CountCommentResponse countByIssue(Long issueId){ + long count = commentRepository.countByIssueId(issueId); + return new CountCommentResponse(issueId, count); + } + + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..09b55b9 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,17 @@ +spring.application.name=issueDive + +# ?????? ?? ?? +spring.datasource.url=jdbc:mysql://localhost:3306/issuedive +spring.datasource.username=root +spring.datasource.password=0661 +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA & Hibernate ?? +spring.jpa.hibernate.ddl-auto=update +spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect + +# --- Jackson --- +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false + +spring.flyway.baseline-on-migrate=true \ No newline at end of file diff --git a/src/test/java/com/issueDive/controller/CommentControllerTest.java b/src/test/java/com/issueDive/controller/CommentControllerTest.java new file mode 100644 index 0000000..77855fc --- /dev/null +++ b/src/test/java/com/issueDive/controller/CommentControllerTest.java @@ -0,0 +1,202 @@ +package com.issueDive.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.issueDive.dto.CommentResponse; +import com.issueDive.dto.CountCommentResponse; +import com.issueDive.dto.CreateCommentRequest; +import com.issueDive.dto.UpdateCommentRequest; +import com.issueDive.exception.CommentNotFoundException; +import com.issueDive.service.CommentService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.security.test.context.support.WithMockUser; + +@WithMockUser +@WebMvcTest(CommentController.class) +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private CommentService commentService; + + private final Long issueId = 1L; + private final Long userId = 1L; + private final Long commentId = 1L; + + @Test + @DisplayName("이슈의 모든 댓글 조회 - 성공") + void getComments_Success() throws Exception { + // given + when(commentService.getTreeByIssue(issueId)).thenReturn(Collections.emptyList()); + + // when & then + mockMvc.perform(get("/issues/{issueId}/comments", issueId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("댓글 생성 - 성공") + void createComment_Success() throws Exception { + // given + CreateCommentRequest request = new CreateCommentRequest(); + request.setDescription("New Comment"); + + CommentResponse response = CommentResponse.builder() + .id(commentId) + .description("New Comment") + .author("testuser") + .createdAt(LocalDateTime.now()) + .build(); + + when(commentService.createComment(eq(issueId), any(CreateCommentRequest.class), eq(userId))).thenReturn(response); + + // when & then + mockMvc.perform(post("/issues/{issueId}/comments", issueId) + .with(csrf()) + .header("X-USER-ID", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.description").value("New Comment")); + } + + @Test + @DisplayName("댓글 수정 - 성공") + void updateComment_Success() throws Exception { + // given + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated Comment"); + + CommentResponse response = CommentResponse.builder() + .id(commentId) + .description("Updated Comment") + .build(); + + when(commentService.updateComment(eq(issueId), eq(commentId), any(UpdateCommentRequest.class), eq(userId))).thenReturn(response); + + // when & then + mockMvc.perform(patch("/issues/{issueId}/comments/{commentId}", issueId, commentId) + .with(csrf()) + .header("X-USER-ID", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.description").value("Updated Comment")); + } + + @Test + @DisplayName("댓글 삭제 - 성공") + void deleteComment_Success() throws Exception { + // given + // when & then + mockMvc.perform(delete("/issues/{issueId}/comments/{commentId}", issueId, commentId) + .with(csrf()) + .header("X-USER-ID", userId)) + .andDo(print()) + .andExpect(status().isNoContent()); + + verify(commentService).deleteComment(issueId, commentId, userId); + } + + @Test + @DisplayName("댓글 생성 - 내용 없음 - 400 Bad Request") + void createComment_BlankDescription_ReturnsBadRequest() throws Exception { + // given + CreateCommentRequest request = new CreateCommentRequest(); + request.setDescription(""); // 유효성 검증에 실패할 내용 + + // when & then + mockMvc.perform(post("/issues/{issueId}/comments", issueId) + .with(csrf()) + .header("X-USER-ID", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("댓글 수정 - 댓글 없음 - 404 Not Found") + void updateComment_CommentNotFound_ReturnsNotFound() throws Exception { + // given + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated Comment"); + + when(commentService.updateComment(eq(issueId), eq(commentId), any(UpdateCommentRequest.class), eq(userId))) + .thenThrow(new CommentNotFoundException("댓글을 찾을 수 없습니다.")); + + // when & then + mockMvc.perform(patch("/issues/{issueId}/comments/{commentId}", issueId, commentId) + .with(csrf()) + .header("X-USER-ID", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("CommentNotFound")); + } + + @Test + @DisplayName("댓글 삭제 - 권한 없음 - 403 Forbidden") + void deleteComment_NotOwner_ReturnsForbidden() throws Exception { + // given + Long notOwnerId = 999L; + doThrow(new SecurityException("댓글 작성자가 아닙니다.")) + .when(commentService).deleteComment(issueId, commentId, notOwnerId); + + // when & then + mockMvc.perform(delete("/issues/{issueId}/comments/{commentId}", issueId, commentId) + .with(csrf()) + .header("X-USER-ID", notOwnerId)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("Forbidden")); + } + + @Test + @DisplayName("댓글 개수 조회 - 성공") + void countComment_Success() throws Exception { + // given + long count = 10L; + when(commentService.countByIssue(issueId)).thenReturn(new CountCommentResponse(issueId, count)); + + // when & then + mockMvc.perform(get("/issues/{issueId}/comments/count", issueId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.issueId").value(issueId)) + .andExpect(jsonPath("$.data.count").value(count)); + } +} diff --git a/src/test/java/com/issueDive/controller/IssueControllerTest.java b/src/test/java/com/issueDive/controller/IssueControllerTest.java index 5cd5eaf..eca117f 100644 --- a/src/test/java/com/issueDive/controller/IssueControllerTest.java +++ b/src/test/java/com/issueDive/controller/IssueControllerTest.java @@ -1,6 +1,5 @@ package com.issueDive.controller; -import com.issueDive.controller.IssueController; import com.issueDive.dto.CreateIssueRequest; import com.issueDive.dto.IssueFilterRequest; import com.issueDive.dto.IssueResponse; diff --git a/src/test/java/com/issueDive/service/CommentServiceTest.java b/src/test/java/com/issueDive/service/CommentServiceTest.java new file mode 100644 index 0000000..c3c9d98 --- /dev/null +++ b/src/test/java/com/issueDive/service/CommentServiceTest.java @@ -0,0 +1,294 @@ +package com.issueDive.service; + +import com.issueDive.dto.CommentResponse; +import com.issueDive.dto.CreateCommentRequest; +import com.issueDive.dto.UpdateCommentRequest; +import com.issueDive.entity.Comment; +import com.issueDive.entity.Issue; +import com.issueDive.entity.User; +import com.issueDive.exception.CommentNotFoundException; +import com.issueDive.exception.NotFoundException; +import com.issueDive.exception.UserNotFoundException; +import com.issueDive.repository.CommentRepository; +import com.issueDive.repository.IssueRepository; +import com.issueDive.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private IssueRepository issueRepository; + + @Test + @DisplayName("댓글 생성 - 성공") + void createComment_Success() { + // given + Long userId = 1L; + Long issueId = 1L; + CreateCommentRequest request = new CreateCommentRequest(); + request.setDescription("Test Comment"); + + User user = User.builder().id(userId).username("testuser").build(); + Issue issue = Issue.builder().id(issueId).build(); + Comment comment = Comment.builder() + .id(1L) + .user(user) + .issue(issue) + .description("Test Comment") + .build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + when(commentRepository.save(any(Comment.class))).thenReturn(comment); + + // when + CommentResponse response = commentService.createComment(issueId, request, userId); + + // then + assertThat(response.getDescription()).isEqualTo("Test Comment"); + assertThat(response.getAuthor()).isEqualTo("testuser"); + verify(commentRepository, times(1)).save(any(Comment.class)); + } + + @Test + @DisplayName("댓글 생성 - 유저 없음 - 실패") + void createComment_UserNotFound() { + // given + Long userId = 1L; + Long issueId = 1L; + CreateCommentRequest request = new CreateCommentRequest(); + request.setDescription("Test Comment"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(UserNotFoundException.class, + () -> commentService.createComment(issueId, request, userId)); + } + + @Test + @DisplayName("댓글 수정 - 성공") + void updateComment_Success() { + // given + Long userId = 1L; + Long issueId = 1L; + Long commentId = 1L; + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated Comment"); + + User user = User.builder().id(userId).build(); + Issue issue = Issue.builder().id(issueId).build(); + Comment comment = Comment.builder() + .id(commentId) + .user(user) + .issue(issue) + .description("Original Comment") + .build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + + // when + CommentResponse response = commentService.updateComment(issueId, commentId, request, userId); + + // then + assertThat(response.getDescription()).isEqualTo("Updated Comment"); + assertThat(comment.getDescription()).isEqualTo("Updated Comment"); + } + + @Test + @DisplayName("댓글 수정 - 댓글 없음 - 실패") + void updateComment_CommentNotFound() { + // given + Long userId = 1L; + Long issueId = 1L; + Long commentId = 1L; + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated Comment"); + + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(CommentNotFoundException.class, + () -> commentService.updateComment(issueId, commentId, request, userId)); + } + + @Test + @DisplayName("댓글 수정 - 작성자 아님 - 실패") + void updateComment_NotOwner() { + // given + Long ownerId = 1L; + Long requesterId = 2L; + Long issueId = 1L; + Long commentId = 1L; + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated Comment"); + + User owner = User.builder().id(ownerId).build(); + Issue issue = Issue.builder().id(issueId).build(); + Comment comment = Comment.builder() + .id(commentId) + .user(owner) + .issue(issue) + .description("Original Comment") + .build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + + // when & then + assertThrows(SecurityException.class, + () -> commentService.updateComment(issueId, commentId, request, requesterId)); + } + + @Test + @DisplayName("댓글 삭제 - 성공") + void deleteComment_Success() { + // given + Long userId = 1L; + Long issueId = 1L; + Long commentId = 1L; + + User user = User.builder().id(userId).build(); + Issue issue = Issue.builder().id(issueId).build(); + Comment comment = Comment.builder() + .id(commentId) + .user(user) + .issue(issue) + .build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + + // when + commentService.deleteComment(issueId, commentId, userId); + + // then + verify(commentRepository, times(1)).delete(comment); + } + + @Test + @DisplayName("대댓글 생성 - 부모 댓글이 다른 이슈 소속 - 실패(400)") + void createComment_ParentInDifferentIssue_ThrowsException() { + // given + Long userId = 1L; + Long issueId = 1L; + Long anotherIssueId = 2L; + Long parentId = 2L; + + CreateCommentRequest request = new CreateCommentRequest(); + request.setDescription("A reply"); + request.setParentId(parentId); + + User user = User.builder().id(userId).build(); + Issue issue = Issue.builder().id(issueId).build(); + Issue anotherIssue = Issue.builder().id(anotherIssueId).build(); + Comment parent = Comment.builder().id(parentId).issue(anotherIssue).build(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + when(commentRepository.findById(parentId)).thenReturn(Optional.of(parent)); + + // when & then + assertThrows(IllegalArgumentException.class, + () -> commentService.createComment(issueId, request, userId)); + } + + @Test + @DisplayName("댓글 수정 - 이슈 불일치 - 실패(400)") + void updateComment_MismatchedIssue_ThrowsException() { + // given + Long userId = 1L; + Long issueId = 1L; + Long anotherIssueId = 2L; + Long commentId = 1L; + UpdateCommentRequest request = new UpdateCommentRequest(); + request.setDescription("Updated content"); + + User user = User.builder().id(userId).build(); + Issue anotherIssue = Issue.builder().id(anotherIssueId).build(); + Comment comment = Comment.builder().id(commentId).user(user).issue(anotherIssue).build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(Issue.builder().id(issueId).build())); + + // when & then + assertThrows(IllegalArgumentException.class, + () -> commentService.updateComment(issueId, commentId, request, userId)); + } + + @Test + @DisplayName("댓글 삭제 - 댓글 없음 - 실패(404)") + void deleteComment_CommentNotFound_ThrowsException() { + // given + Long userId = 1L; + Long issueId = 1L; + Long commentId = 1L; + + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(CommentNotFoundException.class, + () -> commentService.deleteComment(issueId, commentId, userId)); + } + + @Test + @DisplayName("댓글 삭제 - 작성자 아님 - 실패(403)") + void deleteComment_NotOwner_ThrowsException() { + // given + Long ownerId = 1L; + Long requesterId = 2L; + Long issueId = 1L; + Long commentId = 1L; + + User owner = User.builder().id(ownerId).build(); + Issue issue = Issue.builder().id(issueId).build(); + Comment comment = Comment.builder().id(commentId).user(owner).issue(issue).build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(issueRepository.findById(issueId)).thenReturn(Optional.of(issue)); + + // when & then + assertThrows(SecurityException.class, + () -> commentService.deleteComment(issueId, commentId, requesterId)); + } + + @Test + @DisplayName("이슈의 댓글 개수 조회 - 성공") + void countByIssue_Success() { + // given + Long issueId = 1L; + long expectedCount = 5L; + when(commentRepository.countByIssueId(issueId)).thenReturn(expectedCount); + + // when + var response = commentService.countByIssue(issueId); + + // then + assertThat(response.getIssueId()).isEqualTo(issueId); + assertThat(response.getCount()).isEqualTo(expectedCount); + verify(commentRepository, times(1)).countByIssueId(issueId); + } +}