From 6ae78c39790b50e93929323b61ff8e7421c7e14a Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:38:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/back/domain/board/comment/dto/CommentLikeResponse.java --- .../post/controller/PostLikeController.java | 34 ++++++++++++ .../board/post/dto/PostLikeResponse.java | 21 ++++++++ .../back/domain/board/post/entity/Post.java | 16 ++++++ .../domain/board/post/entity/PostLike.java | 4 +- .../post/repository/PostLikeRepository.java | 10 ++++ .../post/repository/PostRepositoryImpl.java | 1 + .../board/post/service/PostLikeService.java | 53 +++++++++++++++++++ .../com/back/global/exception/ErrorCode.java | 2 + 8 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/back/domain/board/post/controller/PostLikeController.java create mode 100644 src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java create mode 100644 src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java create mode 100644 src/main/java/com/back/domain/board/post/service/PostLikeService.java diff --git a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java new file mode 100644 index 00000000..7a6964dd --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java @@ -0,0 +1,34 @@ +package com.back.domain.board.post.controller; + +import com.back.domain.board.post.dto.PostLikeResponse; +import com.back.domain.board.post.service.PostLikeService; +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/posts/{postId}/like") +@RequiredArgsConstructor +public class PostLikeController { + private final PostLikeService postLikeService; + + // 게시글 좋아요 + @GetMapping + public ResponseEntity> likePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostLikeResponse response = postLikeService.likePost(postId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "게시글 좋아요가 등록되었습니다.", + response + )); + } +} diff --git a/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java b/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java new file mode 100644 index 00000000..ff8e7514 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/dto/PostLikeResponse.java @@ -0,0 +1,21 @@ +package com.back.domain.board.post.dto; + +import com.back.domain.board.post.entity.Post; + +/** + * 게시글 좋아요 응답 DTO + * + * @param postId 게시글 id + * @param likeCount 좋아요 수 + */ +public record PostLikeResponse( + Long postId, + Long likeCount +) { + public static PostLikeResponse from(Post post) { + return new PostLikeResponse( + post.getId(), + post.getLikeCount() + ); + } +} diff --git a/src/main/java/com/back/domain/board/post/entity/Post.java b/src/main/java/com/back/domain/board/post/entity/Post.java index b09a655a..c8697d0b 100644 --- a/src/main/java/com/back/domain/board/post/entity/Post.java +++ b/src/main/java/com/back/domain/board/post/entity/Post.java @@ -22,6 +22,10 @@ public class Post extends BaseEntity { private String content; + // TODO: 추후 PostRepositoryImpl#searchPosts 로직 개선 필요, ERD에도 반영할 것 + @Column(nullable = false) + private Long likeCount = 0L; + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List postCategoryMappings = new ArrayList<>(); @@ -56,6 +60,18 @@ public void updateCategories(List categories) { ); } + // 좋아요 수 증가 + public void increaseLikeCount() { + this.likeCount++; + } + + // 좋아요 수 감소 + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + // -------------------- 헬퍼 메서드 -------------------- // 게시글에 연결된 카테고리 목록 조회 public List getCategories() { diff --git a/src/main/java/com/back/domain/board/post/entity/PostLike.java b/src/main/java/com/back/domain/board/post/entity/PostLike.java index 87d33e49..89a5e6b6 100644 --- a/src/main/java/com/back/domain/board/post/entity/PostLike.java +++ b/src/main/java/com/back/domain/board/post/entity/PostLike.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 -@NoArgsConstructor @Getter +@NoArgsConstructor +@AllArgsConstructor public class PostLike extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") diff --git a/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java b/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java new file mode 100644 index 00000000..4a593cc3 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java @@ -0,0 +1,10 @@ +package com.back.domain.board.post.repository; + +import com.back.domain.board.post.entity.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostLikeRepository extends JpaRepository { + boolean existsByUserIdAndPostId(Long userId, Long postId); +} diff --git a/src/main/java/com/back/domain/board/post/repository/PostRepositoryImpl.java b/src/main/java/com/back/domain/board/post/repository/PostRepositoryImpl.java index 59b02d72..b43d1991 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/back/domain/board/post/repository/PostRepositoryImpl.java @@ -29,6 +29,7 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory queryFactory; + // TODO: Post에 likeCount 필드 추가에 따른 로직 개선 /** * 게시글 다건 검색 * - 총 쿼리 수 : 3회 (Post + Category + count) diff --git a/src/main/java/com/back/domain/board/post/service/PostLikeService.java b/src/main/java/com/back/domain/board/post/service/PostLikeService.java new file mode 100644 index 00000000..04fb2439 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/service/PostLikeService.java @@ -0,0 +1,53 @@ +package com.back.domain.board.post.service; + +import com.back.domain.board.post.dto.PostLikeResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostLike; +import com.back.domain.board.post.repository.PostLikeRepository; +import com.back.domain.board.post.repository.PostRepository; +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 PostLikeService { + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final UserRepository userRepository; + + /** + * 게시글 좋아요 서비스 + * 1. User 조회 + * 2. Post 조회 + * 3. 이미 존재하는 경우 예외 처리 + * 4. PostLike 저장 및 likeCount 증가 + * 5. PostLikeResponse 반환 + */ + public PostLikeResponse likePost(Long postId, Long userId) { + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 이미 좋아요를 누른 경우 예외 + if (postLikeRepository.existsByUserIdAndPostId(userId, postId)) { + throw new CustomException(ErrorCode.POST_ALREADY_LIKED); + } + + // 좋아요 수 증가 + post.increaseLikeCount(); + + // PostLike 저장 및 응답 반환 + postLikeRepository.save(new PostLike(post, user)); + return PostLikeResponse.from(post); + } +} diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 0dc72a38..447a52d8 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -97,6 +97,8 @@ public enum ErrorCode { POST_NO_PERMISSION(HttpStatus.FORBIDDEN, "POST_002", "게시글 작성자만 수정/삭제할 수 있습니다."), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_003", "존재하지 않는 카테고리입니다."), CATEGORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "POST_004", "이미 존재하는 카테고리입니다."), + POST_ALREADY_LIKED(HttpStatus.CONFLICT, "POST_005", "이미 좋아요한 게시글입니다."), + POST_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_006", "해당 게시글에 대한 좋아요 기록이 없습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않는 댓글입니다."), COMMENT_NO_PERMISSION(HttpStatus.FORBIDDEN, "COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다."), From 4d322423bee76bf71e3ab210db80ed32d01b718b Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:44:23 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostLikeController.java | 19 +++++++++--- .../post/repository/PostLikeRepository.java | 3 ++ .../board/post/service/PostLikeService.java | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java index 7a6964dd..a492c5db 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java @@ -7,10 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/posts/{postId}/like") @@ -31,4 +28,18 @@ public ResponseEntity> likePost( response )); } + + // 게시글 좋아요 취소 + @DeleteMapping + public ResponseEntity> cancelLikePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostLikeResponse response = postLikeService.cancelLikePost(postId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "게시글 좋아요가 취소되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java b/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java index 4a593cc3..1b086d1c 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostLikeRepository.java @@ -4,7 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface PostLikeRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); + Optional findByUserIdAndPostId(Long userId, Long postId); } diff --git a/src/main/java/com/back/domain/board/post/service/PostLikeService.java b/src/main/java/com/back/domain/board/post/service/PostLikeService.java index 04fb2439..91e270e3 100644 --- a/src/main/java/com/back/domain/board/post/service/PostLikeService.java +++ b/src/main/java/com/back/domain/board/post/service/PostLikeService.java @@ -50,4 +50,35 @@ public PostLikeResponse likePost(Long postId, Long userId) { postLikeRepository.save(new PostLike(post, user)); return PostLikeResponse.from(post); } + + /** + * 게시글 좋아요 취소 서비스 + * 1. User 조회 + * 2. Post 조회 + * 3. PostLike 조회 + * 4. PostLike 삭제 및 likeCount 감소 + * 5. PostLikeResponse 반환 + */ + public PostLikeResponse cancelLikePost(Long postId, Long userId) { + // User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // PostLike 조회 + PostLike postLike = postLikeRepository.findByUserIdAndPostId(userId, postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_LIKE_NOT_FOUND)); + + // PostLike 삭제 + postLikeRepository.delete(postLike); + + // 좋아요 수 감소 + post.decreaseLikeCount(); + + // 응답 반환 + return PostLikeResponse.from(post); + } } From f0142edd34a1c4bf804ba2dbbfc9aafbef683905 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:57:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostLikeController.java | 2 +- .../controller/PostLikeControllerTest.java | 300 ++++++++++++++++++ .../board/service/PostLikeServiceTest.java | 141 ++++++++ 3 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/back/domain/board/controller/PostLikeControllerTest.java create mode 100644 src/test/java/com/back/domain/board/service/PostLikeServiceTest.java diff --git a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java index a492c5db..81199929 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java @@ -16,7 +16,7 @@ public class PostLikeController { private final PostLikeService postLikeService; // 게시글 좋아요 - @GetMapping + @PostMapping public ResponseEntity> likePost( @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails user diff --git a/src/test/java/com/back/domain/board/controller/PostLikeControllerTest.java b/src/test/java/com/back/domain/board/controller/PostLikeControllerTest.java new file mode 100644 index 00000000..8a1aad20 --- /dev/null +++ b/src/test/java/com/back/domain/board/controller/PostLikeControllerTest.java @@ -0,0 +1,300 @@ +package com.back.domain.board.controller; + +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.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class PostLikeControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TestJwtTokenProvider testJwtTokenProvider; + + @Autowired + private ObjectMapper objectMapper; + + private String generateAccessToken(User user) { + return testJwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + } + + // ====================== 게시글 좋아요 등록 ====================== + + @Test + @DisplayName("게시글 좋아요 등록 성공 → 200 OK") + void likePost_success() throws Exception { + // given + User user = User.createUser("likeUser", "like@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "좋아요 테스트", "내용입니다"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // when + mvc.perform(post("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("게시글 좋아요가 등록되었습니다.")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.likeCount").value(1)); + } + + @Test + @DisplayName("게시글 좋아요 실패 - 존재하지 않는 사용자 → 404 Not Found") + void likePost_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // 실제 게시글은 존재 + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + Post post = new Post(writer, "게시글", "내용"); + postRepository.save(post); + + // when & then + mvc.perform(post("/api/posts/{postId}/like", post.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 likePost_postNotFound() throws Exception { + // given + User user = User.createUser("likeUser2", "like2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(post("/api/posts/{postId}/like", 999L) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("게시글 좋아요 실패 - 이미 좋아요 누름 → 409 Conflict") + void likePost_alreadyLiked() throws Exception { + // given + User user = User.createUser("likeUser3", "like3@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "테스트 게시글", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // 먼저 좋아요 1회 + mvc.perform(post("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + // 같은 요청 반복 + mvc.perform(post("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("POST_005")) + .andExpect(jsonPath("$.message").value("이미 좋아요한 게시글입니다.")); + } + + @Test + @DisplayName("게시글 좋아요 실패 - 토큰 없음 → 401 Unauthorized") + void likePost_noToken() throws Exception { + // given + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + Post post = new Post(writer, "테스트", "내용"); + postRepository.save(post); + + // when & then + mvc.perform(post("/api/posts/{postId}/like", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + // ====================== 게시글 좋아요 취소 ====================== + + @Test + @DisplayName("게시글 좋아요 취소 성공 → 200 OK") + void cancelLikePost_success() throws Exception { + // given + User user = User.createUser("cancelUser", "cancel@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "좋아요 취소 테스트", "내용입니다"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // 먼저 좋아요 등록 + mvc.perform(post("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + // when: 좋아요 취소 + mvc.perform(delete("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("게시글 좋아요가 취소되었습니다.")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.likeCount").value(0)); + } + + @Test + @DisplayName("게시글 좋아요 취소 실패 - 존재하지 않는 사용자 → 404 Not Found") + void cancelLikePost_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // 실제 게시글 존재 + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + Post post = new Post(writer, "게시글", "내용"); + postRepository.save(post); + + // when & then + mvc.perform(delete("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + fakeToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("게시글 좋아요 취소 실패 - 존재하지 않는 게시글 → 404 Not Found") + void cancelLikePost_postNotFound() throws Exception { + // given + User user = User.createUser("cancelUser2", "cancel2@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/like", 999L) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } + + @Test + @DisplayName("게시글 좋아요 취소 실패 - 좋아요 기록 없음 → 404 Not Found") + void cancelLikePost_notFound() throws Exception { + // given + User user = User.createUser("cancelUser3", "cancel3@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "작성자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "테스트 게시글", "내용"); + postRepository.save(post); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/posts/{postId}/like", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_006")) + .andExpect(jsonPath("$.message").value("해당 게시글에 대한 좋아요 기록이 없습니다.")); + } + + @Test + @DisplayName("게시글 좋아요 취소 실패 - 토큰 없음 → 401 Unauthorized") + void cancelLikePost_noToken() throws Exception { + // given + User writer = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + writer.setUserProfile(new UserProfile(writer, "작성자", null, null, null, 0)); + writer.setUserStatus(UserStatus.ACTIVE); + userRepository.save(writer); + + Post post = new Post(writer, "테스트", "내용"); + postRepository.save(post); + + // when & then + mvc.perform(delete("/api/posts/{postId}/like", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } +} diff --git a/src/test/java/com/back/domain/board/service/PostLikeServiceTest.java b/src/test/java/com/back/domain/board/service/PostLikeServiceTest.java new file mode 100644 index 00000000..a1db83bf --- /dev/null +++ b/src/test/java/com/back/domain/board/service/PostLikeServiceTest.java @@ -0,0 +1,141 @@ +package com.back.domain.board.service; + +import com.back.domain.board.post.dto.PostLikeResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostLike; +import com.back.domain.board.post.repository.PostLikeRepository; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.board.post.service.PostLikeService; +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 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 org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class PostLikeServiceTest { + + @Autowired + private PostLikeService postLikeService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostLikeRepository postLikeRepository; + + @Autowired + private UserRepository userRepository; + + // ====================== 게시글 좋아요 테스트 ====================== + + @Test + @DisplayName("게시글 좋아요 성공") + void likePost_success() { + // given + User 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 post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // when + PostLikeResponse response = postLikeService.likePost(post.getId(), user.getId()); + + // then + assertThat(response.likeCount()).isEqualTo(1); + assertThat(postLikeRepository.existsByUserIdAndPostId(user.getId(), post.getId())).isTrue(); + } + + @Test + @DisplayName("게시글 좋아요 실패 - 존재하지 않는 게시글") + void likePost_fail_postNotFound() { + // given + User user = User.createUser("user2", "user2@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // when & then + assertThatThrownBy(() -> postLikeService.likePost(999L, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 좋아요 실패 - 이미 좋아요한 경우") + void likePost_fail_alreadyLiked() { + // given + User user = User.createUser("user3", "user3@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + postLikeRepository.save(new PostLike(post, user)); + + // when & then + assertThatThrownBy(() -> postLikeService.likePost(post.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_ALREADY_LIKED.getMessage()); + } + + // ====================== 게시글 좋아요 취소 테스트 ====================== + + @Test + @DisplayName("게시글 좋아요 취소 성공") + void cancelLikePost_success() { + // given + User user = User.createUser("user4", "user4@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + PostLike postLike = new PostLike(post, user); + postLikeRepository.save(postLike); + post.increaseLikeCount(); + + // when + PostLikeResponse response = postLikeService.cancelLikePost(post.getId(), user.getId()); + + // then + assertThat(response.likeCount()).isEqualTo(0); + assertThat(postLikeRepository.existsByUserIdAndPostId(user.getId(), post.getId())).isFalse(); + } + + @Test + @DisplayName("게시글 좋아요 취소 실패 - 좋아요 내역 없음") + void cancelLikePost_fail_notFound() { + // given + User user = User.createUser("user5", "user5@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "제목", "내용"); + postRepository.save(post); + + // when & then + assertThatThrownBy(() -> postLikeService.cancelLikePost(post.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_LIKE_NOT_FOUND.getMessage()); + } +} From 5d3f170a15f989a70299d6a3b13641d2b6b7ff0b Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:59:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostLikeController.java | 2 +- .../controller/PostLikeControllerDocs.java | 275 ++++++++++++++++++ 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/back/domain/board/post/controller/PostLikeControllerDocs.java diff --git a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java index 81199929..61298e7b 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostLikeController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostLikeController.java @@ -12,7 +12,7 @@ @RestController @RequestMapping("/api/posts/{postId}/like") @RequiredArgsConstructor -public class PostLikeController { +public class PostLikeController implements PostLikeControllerDocs { private final PostLikeService postLikeService; // 게시글 좋아요 diff --git a/src/main/java/com/back/domain/board/post/controller/PostLikeControllerDocs.java b/src/main/java/com/back/domain/board/post/controller/PostLikeControllerDocs.java new file mode 100644 index 00000000..3ab347a7 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostLikeControllerDocs.java @@ -0,0 +1,275 @@ +package com.back.domain.board.post.controller; + +import com.back.domain.board.post.dto.PostLikeResponse; +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 = "Post Like API", description = "게시글 좋아요 관련 API") +public interface PostLikeControllerDocs { + + @Operation( + summary = "게시글 좋아요 등록", + description = "로그인한 사용자가 특정 게시글에 좋아요를 등록합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 좋아요 등록 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글 좋아요가 등록되었습니다.", + "data": { + "postId": 101, + "likeCount": 11 + } + } + """) + ) + ), + @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": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 좋아요한 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_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> likePost( + @PathVariable Long postId, + @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": { + "postId": 101, + "likeCount": 10 + } + } + """) + ) + ), + @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": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """), + @ExampleObject(name = "좋아요 기록 없음", value = """ + { + "success": false, + "code": "POST_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> cancelLikePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ); +}