diff --git a/src/main/java/com/back/domain/board/comment/controller/CommentLikeController.java b/src/main/java/com/back/domain/board/comment/controller/CommentLikeController.java new file mode 100644 index 00000000..1db63f82 --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/controller/CommentLikeController.java @@ -0,0 +1,47 @@ +package com.back.domain.board.comment.controller; + +import com.back.domain.board.comment.dto.CommentLikeResponse; +import com.back.domain.board.comment.service.CommentLikeService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts/{postId}/comments/{commentId}/like") +@RequiredArgsConstructor +public class CommentLikeController implements CommentLikeControllerDocs { + private final CommentLikeService commentLikeService; + + // 댓글 좋아요 + @PostMapping + public ResponseEntity> likeComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ) { + CommentLikeResponse response = commentLikeService.likeComment(commentId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "댓글 좋아요가 등록되었습니다.", + response + )); + } + + // 댓글 좋아요 취소 + @DeleteMapping + public ResponseEntity> cancelLikeComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ) { + CommentLikeResponse response = commentLikeService.cancelLikeComment(commentId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "댓글 좋아요가 취소되었습니다.", + response + )); + } +} diff --git a/src/main/java/com/back/domain/board/comment/controller/CommentLikeControllerDocs.java b/src/main/java/com/back/domain/board/comment/controller/CommentLikeControllerDocs.java new file mode 100644 index 00000000..23ef9d8b --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/controller/CommentLikeControllerDocs.java @@ -0,0 +1,277 @@ +package com.back.domain.board.comment.controller; + +import com.back.domain.board.comment.dto.CommentLikeResponse; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "Comment Like API", description = "댓글 좋아요 관련 API") +public interface CommentLikeControllerDocs { + + @Operation( + summary = "댓글 좋아요 등록", + description = "로그인한 사용자가 특정 댓글에 좋아요를 등록합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 좋아요 등록 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_201", + "message": "댓글 좋아요가 등록되었습니다.", + "data": { + "commentId": 25, + "likeCount": 4 + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파라미터 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (Access Token 없음/만료/잘못됨)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자 또는 댓글", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 사용자", value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 댓글", value = """ + { + "success": false, + "code": "COMMENT_001", + "message": "존재하지 않는 댓글입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 좋아요한 댓글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMENT_005", + "message": "이미 좋아요한 댓글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> likeComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ); + + @Operation( + summary = "댓글 좋아요 취소", + description = "로그인한 사용자가 특정 댓글에 등록한 좋아요를 취소합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "댓글 좋아요 취소 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "댓글 좋아요가 취소되었습니다.", + "data": { + "commentId": 25, + "likeCount": 3 + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파라미터 누락 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (Access Token 없음/만료/잘못됨)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자, 댓글 또는 좋아요 기록 없음", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "존재하지 않는 사용자", value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """), + @ExampleObject(name = "존재하지 않는 댓글", value = """ + { + "success": false, + "code": "COMMENT_001", + "message": "존재하지 않는 댓글입니다.", + "data": null + } + """), + @ExampleObject(name = "좋아요 기록 없음", value = """ + { + "success": false, + "code": "COMMENT_006", + "message": "해당 댓글에 대한 좋아요 기록이 없습니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> cancelLikeComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails user + ); +} diff --git a/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java b/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java new file mode 100644 index 00000000..8aa94a87 --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java @@ -0,0 +1,21 @@ +package com.back.domain.board.comment.dto; + +import com.back.domain.board.comment.entity.Comment; + +/** + * 댓글 좋아요 응답 DTO + * + * @param commentId + * @param likeCount + */ +public record CommentLikeResponse( + Long commentId, + Long likeCount +) { + public static CommentLikeResponse from(Comment comment) { + return new CommentLikeResponse( + comment.getId(), + comment.getLikeCount() + ); + } +} diff --git a/src/main/java/com/back/domain/board/comment/entity/Comment.java b/src/main/java/com/back/domain/board/comment/entity/Comment.java index e91ab6c3..dc7d17b4 100644 --- a/src/main/java/com/back/domain/board/comment/entity/Comment.java +++ b/src/main/java/com/back/domain/board/comment/entity/Comment.java @@ -24,6 +24,10 @@ public class Comment extends BaseEntity { private String content; + // TODO: 추후 CommentRepositoryImpl#getCommentsByPostId 로직 개선 필요, ERD에도 반영할 것 + @Column(nullable = false) + private Long likeCount = 0L; + // 해당 댓글의 부모 댓글 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_comment_id") @@ -49,10 +53,22 @@ public Comment(Post post, User user, String content, Comment parent) { this.content = content; this.parent = parent; } - + // -------------------- 비즈니스 메서드 -------------------- // 댓글 업데이트 public void update(String content) { this.content = content; } + + // 좋아요 수 증가 + public void increaseLikeCount() { + this.likeCount++; + } + + // 좋아요 수 감소 + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } diff --git a/src/main/java/com/back/domain/board/comment/entity/CommentLike.java b/src/main/java/com/back/domain/board/comment/entity/CommentLike.java index 6196ddf4..2672040e 100644 --- a/src/main/java/com/back/domain/board/comment/entity/CommentLike.java +++ b/src/main/java/com/back/domain/board/comment/entity/CommentLike.java @@ -6,12 +6,14 @@ import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter @NoArgsConstructor +@AllArgsConstructor public class CommentLike extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "comment_id") diff --git a/src/main/java/com/back/domain/board/comment/repository/CommentLikeRepository.java b/src/main/java/com/back/domain/board/comment/repository/CommentLikeRepository.java new file mode 100644 index 00000000..47a3a996 --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/repository/CommentLikeRepository.java @@ -0,0 +1,13 @@ +package com.back.domain.board.comment.repository; + +import com.back.domain.board.comment.entity.CommentLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CommentLikeRepository extends JpaRepository { + boolean existsByUserIdAndCommentId(Long userId, Long commentId); + Optional findByUserIdAndCommentId(Long userId, Long commentId); +} diff --git a/src/main/java/com/back/domain/board/comment/repository/CommentRepositoryImpl.java b/src/main/java/com/back/domain/board/comment/repository/CommentRepositoryImpl.java index e0c4d9fc..11ae4c52 100644 --- a/src/main/java/com/back/domain/board/comment/repository/CommentRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/comment/repository/CommentRepositoryImpl.java @@ -21,9 +21,9 @@ @RequiredArgsConstructor public class CommentRepositoryImpl implements CommentRepositoryCustom { - private final JPAQueryFactory queryFactory; + // TODO: Comment에 likeCount 필드 추가에 따른 로직 개선 /** * 게시글 ID로 댓글 목록 조회 * - 부모 댓글 페이징 + 자식 댓글 전체 조회 diff --git a/src/main/java/com/back/domain/board/comment/service/CommentLikeService.java b/src/main/java/com/back/domain/board/comment/service/CommentLikeService.java new file mode 100644 index 00000000..192b901f --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/service/CommentLikeService.java @@ -0,0 +1,82 @@ +package com.back.domain.board.comment.service; + +import com.back.domain.board.comment.dto.CommentLikeResponse; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.entity.CommentLike; +import com.back.domain.board.comment.repository.CommentLikeRepository; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentLikeService { + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final UserRepository userRepository; + + /** + * 댓글 좋아요 서비스 + * 1. User 조회 + * 2. Comment 조회 + * 3. 이미 존재하는 경우 예외 처리 + * 4. CommentLike 저장 및 likeCount 증가 + */ + public CommentLikeResponse likeComment(Long commentId, Long userId) { + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 이미 좋아요를 누른 경우 예외 + if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { + throw new CustomException(ErrorCode.COMMENT_ALREADY_LIKED); + } + + // 좋아요 수 증가 + comment.increaseLikeCount(); + + // CommentLike 저장 및 응답 반환 + commentLikeRepository.save(new CommentLike(comment, user)); + return CommentLikeResponse.from(comment); + } + + /** + * 댓글 좋아요 취소 서비스 + * 1. User 조회 + * 2. Comment 조회 + * 3. CommentLike 조회 + * 4. CommentLike 삭제 및 likeCount 감소 + */ + public CommentLikeResponse cancelLikeComment(Long commentId, Long userId) { + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // CommentLike 조회 + CommentLike commentLike = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_LIKE_NOT_FOUND)); + + // CommentLike 삭제 + commentLikeRepository.delete(commentLike); + + // 좋아요 수 감소 + comment.decreaseLikeCount(); + + // 응답 반환 + return CommentLikeResponse.from(comment); + } +} diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index bf14e3e9..3f54ce63 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -102,6 +102,8 @@ public enum ErrorCode { COMMENT_NO_PERMISSION(HttpStatus.FORBIDDEN, "COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다."), COMMENT_PARENT_MISMATCH(HttpStatus.BAD_REQUEST, "COMMENT_003", "부모 댓글이 해당 게시글에 속하지 않습니다."), COMMENT_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "COMMENT_004", "대댓글은 한 단계까지만 작성할 수 있습니다."), + COMMENT_ALREADY_LIKED(HttpStatus.CONFLICT, "COMMENT_005", "이미 좋아요한 댓글입니다."), + COMMENT_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_006", "해당 댓글에 대한 좋아요 기록이 없습니다."), // ======================== 공통 에러 ======================== BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), diff --git a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index 3d23fa6e..dc32c890 100644 --- a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -5,12 +5,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; import java.time.LocalDate; @@ -67,6 +69,20 @@ public ResponseEntity> handleMissingParam(MissingServletRequestPara .body(RsData.fail(ErrorCode.BAD_REQUEST)); } + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(RsData.fail(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(RsData.fail(ErrorCode.BAD_REQUEST)); + } + @ExceptionHandler(SecurityException.class) public ResponseEntity> handleSecurityException(SecurityException ex) { return ResponseEntity diff --git a/src/test/java/com/back/domain/board/controller/CommentLikeControllerTest.java b/src/test/java/com/back/domain/board/controller/CommentLikeControllerTest.java new file mode 100644 index 00000000..5ccdd76c --- /dev/null +++ b/src/test/java/com/back/domain/board/controller/CommentLikeControllerTest.java @@ -0,0 +1,335 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentLikeRepository; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.fixture.TestJwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class CommentLikeControllerTest { + + @Autowired + private MockMvc mvc; + @Autowired + private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private CommentLikeRepository commentLikeRepository; + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private ObjectMapper objectMapper; + + private String generateAccessToken(User user) { + return testJwtTokenProvider.createAccessToken( + user.getId(), + user.getUsername(), + user.getRole().name() + ); + } + + private User createUser(String username, String email) { + User user = User.createUser(username, email, passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, username, null, "소개", LocalDate.of(2000, 1, 1), 0)); + user.setUserStatus(UserStatus.ACTIVE); + return userRepository.save(user); + } + + private Post createPost(User user) { + Post post = new Post(user, "게시글 제목", "게시글 내용"); + return postRepository.save(post); + } + + // ====================== 댓글 좋아요 등록 ====================== + + @Test + @DisplayName("댓글 좋아요 등록 성공 → 201 Created") + void likeComment_success() throws Exception { + User user = createUser("writer", "writer@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글 내용")); + + String token = generateAccessToken(user); + + ResultActions result = mvc.perform( + post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + ).andDo(print()); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("댓글 좋아요가 등록되었습니다.")) + .andExpect(jsonPath("$.data.commentId").value(comment.getId())) + .andExpect(jsonPath("$.data.likeCount").value(1)); + + assertThat(commentLikeRepository.existsByUserIdAndCommentId(user.getId(), comment.getId())).isTrue(); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 존재하지 않는 사용자 → 404 Not Found") + void likeComment_fail_userNotFound() throws Exception { + User user = createUser("temp", "temp@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + fakeToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 존재하지 않는 댓글 → 404 Not Found") + void likeComment_fail_commentNotFound() throws Exception { + User user = createUser("temp", "temp@example.com"); + Post post = createPost(user); + String token = generateAccessToken(user); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), 999L) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("COMMENT_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 댓글입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 이미 좋아요 누름 → 409 Conflict") + void likeComment_fail_alreadyLiked() throws Exception { + User user = createUser("user", "user@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + String token = generateAccessToken(user); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("COMMENT_005")) + .andExpect(jsonPath("$.message").value("이미 좋아요한 댓글입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 잘못된 요청(파라미터 누락) → 400 Bad Request") + void likeComment_fail_badRequest() throws Exception { + User user = createUser("user", "user@example.com"); + Post post = createPost(user); + String token = generateAccessToken(user); + + mvc.perform(post("/api/posts/{postId}/comments//like", post.getId()) // commentId 누락 + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 인증 실패 (토큰 없음) → 401 Unauthorized") + void likeComment_fail_noToken() throws Exception { + User user = createUser("user", "user@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 잘못된 토큰 → 401 Unauthorized") + void likeComment_fail_invalidToken() throws Exception { + User user = createUser("user", "user@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer invalid.token.here") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + // ====================== 댓글 좋아요 취소 ====================== + + @Test + @DisplayName("댓글 좋아요 취소 성공 → 200 OK") + void cancelLikeComment_success() throws Exception { + // given + User user = createUser("cancel", "cancel@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + String token = generateAccessToken(user); + + // 좋아요 등록 + mvc.perform(post("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // when + ResultActions result = mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("댓글 좋아요가 취소되었습니다.")) + .andExpect(jsonPath("$.data.commentId").value(comment.getId())) + .andExpect(jsonPath("$.data.likeCount").value(0)); + + assertThat(commentLikeRepository.existsByUserIdAndCommentId(user.getId(), comment.getId())).isFalse(); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 좋아요 기록 없음 → 404 Not Found") + void cancelLikeComment_fail_notLiked() throws Exception { + // given + User user = createUser("user2", "user2@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + String token = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value("COMMENT_006")) + .andExpect(jsonPath("$.message").value("해당 댓글에 대한 좋아요 기록이 없습니다.")); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 존재하지 않는 사용자 → 404 Not Found") + void cancelLikeComment_fail_userNotFound() throws Exception { + // given + User user = createUser("temp", "temp@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer " + fakeToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 존재하지 않는 댓글 → 404 Not Found") + void cancelLikeComment_fail_commentNotFound() throws Exception { + // given + User user = createUser("writer", "writer@example.com"); + Post post = createPost(user); + String token = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), 999L) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value("COMMENT_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 댓글입니다.")); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 인증 실패 (토큰 없음) → 401 Unauthorized") + void cancelLikeComment_fail_noToken() throws Exception { + // given + User user = createUser("writer", "writer@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 잘못된 토큰 → 401 Unauthorized") + void cancelLikeComment_fail_invalidToken() throws Exception { + // given + User user = createUser("writer", "writer@example.com"); + Post post = createPost(user); + Comment comment = commentRepository.save(new Comment(post, user, "댓글")); + + // when & then + mvc.perform(delete("/api/posts/{postId}/comments/{commentId}/like", post.getId(), comment.getId()) + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } +} diff --git a/src/test/java/com/back/domain/board/service/CommentLikeServiceTest.java b/src/test/java/com/back/domain/board/service/CommentLikeServiceTest.java new file mode 100644 index 00000000..c0c64ac4 --- /dev/null +++ b/src/test/java/com/back/domain/board/service/CommentLikeServiceTest.java @@ -0,0 +1,154 @@ +package com.back.domain.board.service; + +import com.back.domain.board.comment.dto.CommentLikeResponse; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentLikeRepository; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.comment.service.CommentLikeService; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class CommentLikeServiceTest { + + @Autowired + private CommentLikeService commentLikeService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private CommentLikeRepository commentLikeRepository; + + private User user; + private Post post; + private Comment comment; + + @BeforeEach + void setUp() { + user = User.createUser("user1", "user1@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + post = new Post(user, "게시글 제목", "게시글 내용"); + postRepository.save(post); + + comment = new Comment(post, user, "댓글 내용"); + commentRepository.save(comment); + } + + // ====================== 좋아요 추가 테스트 ====================== + + @Test + @DisplayName("댓글 좋아요 성공") + void likeComment_success() { + // when + CommentLikeResponse response = commentLikeService.likeComment(comment.getId(), user.getId()); + + // then + assertThat(response.commentId()).isEqualTo(comment.getId()); + assertThat(response.likeCount()).isEqualTo(1L); + assertThat(commentLikeRepository.existsByUserIdAndCommentId(user.getId(), comment.getId())).isTrue(); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 이미 좋아요한 댓글") + void likeComment_fail_alreadyLiked() { + // given + commentLikeService.likeComment(comment.getId(), user.getId()); + + // when & then + assertThatThrownBy(() -> commentLikeService.likeComment(comment.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_ALREADY_LIKED.getMessage()); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 존재하지 않는 댓글") + void likeComment_fail_commentNotFound() { + // when & then + assertThatThrownBy(() -> commentLikeService.likeComment(999L, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 좋아요 실패 - 존재하지 않는 유저") + void likeComment_fail_userNotFound() { + // when & then + assertThatThrownBy(() -> commentLikeService.likeComment(comment.getId(), 999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + // ====================== 좋아요 취소 테스트 ====================== + + @Test + @DisplayName("댓글 좋아요 취소 성공") + void cancelLikeComment_success() { + // given + commentLikeService.likeComment(comment.getId(), user.getId()); + + // when + CommentLikeResponse response = commentLikeService.cancelLikeComment(comment.getId(), user.getId()); + + // then + assertThat(response.commentId()).isEqualTo(comment.getId()); + assertThat(response.likeCount()).isEqualTo(0L); + assertThat(commentLikeRepository.existsByUserIdAndCommentId(user.getId(), comment.getId())).isFalse(); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 좋아요하지 않은 댓글") + void cancelLikeComment_fail_notLiked() { + // when & then + assertThatThrownBy(() -> commentLikeService.cancelLikeComment(comment.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_LIKE_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 존재하지 않는 댓글") + void cancelLikeComment_fail_commentNotFound() { + // when & then + assertThatThrownBy(() -> commentLikeService.cancelLikeComment(999L, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.COMMENT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("댓글 좋아요 취소 실패 - 존재하지 않는 유저") + void cancelLikeComment_fail_userNotFound() { + // given + commentLikeService.likeComment(comment.getId(), user.getId()); + + // when & then + assertThatThrownBy(() -> commentLikeService.cancelLikeComment(comment.getId(), 999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } +}