From 1e74ed03b48067f96fd013ebea752df6766a6b6c Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:27:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PostBookmarkController.java | 35 ++++++++++++ .../board/post/dto/PostBookmarkResponse.java | 21 ++++++++ .../board/post/entity/PostBookmark.java | 12 +++-- .../repository/PostBookmarkRepository.java | 10 ++++ .../post/service/PostBookmarkService.java | 53 +++++++++++++++++++ .../com/back/global/exception/ErrorCode.java | 2 + 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java create mode 100644 src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java create mode 100644 src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java create mode 100644 src/main/java/com/back/domain/board/post/service/PostBookmarkService.java diff --git a/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java new file mode 100644 index 00000000..08d2b000 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java @@ -0,0 +1,35 @@ +package com.back.domain.board.post.controller; + +import com.back.domain.board.post.dto.PostBookmarkResponse; +import com.back.domain.board.post.service.PostBookmarkService; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/posts/{postId}/bookmark") +@RequiredArgsConstructor +public class PostBookmarkController { + private final PostBookmarkService bookmarkService; + private final PostBookmarkService postBookmarkService; + + // 게시글 북마크 + @PostMapping + public ResponseEntity> bookmarkPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostBookmarkResponse response = postBookmarkService.bookmarkPost(postId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "게시글 북마크가 등록되었습니다.", + response + )); + } +} diff --git a/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java b/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.java new file mode 100644 index 00000000..f4483711 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/dto/PostBookmarkResponse.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 bookmarkCount 북마크 수 + */ +public record PostBookmarkResponse( + Long postId, + Long bookmarkCount +) { + public static PostBookmarkResponse from(Post post) { + return new PostBookmarkResponse( + post.getId(), + post.getBookmarkCount() + ); + } +} diff --git a/src/main/java/com/back/domain/board/post/entity/PostBookmark.java b/src/main/java/com/back/domain/board/post/entity/PostBookmark.java index 113b29f5..78b10c1f 100644 --- a/src/main/java/com/back/domain/board/post/entity/PostBookmark.java +++ b/src/main/java/com/back/domain/board/post/entity/PostBookmark.java @@ -2,16 +2,20 @@ import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter @NoArgsConstructor +@AllArgsConstructor +@Table( + uniqueConstraints = { + @UniqueConstraint(columnNames = {"post_id", "user_id"}) + } +) public class PostBookmark extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") diff --git a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java new file mode 100644 index 00000000..350e0b11 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java @@ -0,0 +1,10 @@ +package com.back.domain.board.post.repository; + +import com.back.domain.board.post.entity.PostBookmark; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostBookmarkRepository extends JpaRepository { + boolean existsByUserIdAndPostId(Long userId, Long postId); +} diff --git a/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java b/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java new file mode 100644 index 00000000..896acea4 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java @@ -0,0 +1,53 @@ +package com.back.domain.board.post.service; + +import com.back.domain.board.post.dto.PostBookmarkResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; +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 PostBookmarkService { + private final PostRepository postRepository; + private final PostBookmarkRepository postBookmarkRepository; + private final UserRepository userRepository; + + /** + * 게시글 북마크 서비스 + * 1. User 조회 + * 2. Post 조회 + * 3. 이미 존재하는 경우 예외 처리 + * 4. PostBookmark 저장 및 bookmarkCount 증가 + * 5. PostBookmarkResponse 반환 + */ + public PostBookmarkResponse bookmarkPost(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 (postBookmarkRepository.existsByUserIdAndPostId(userId, postId)) { + throw new CustomException(ErrorCode.BOOKMARK_ALREADY_EXISTS); + } + + // 북마크 수 증가 + post.increaseBookmarkCount(); + + // PostBookmark 저장 및 응답 반환 + postBookmarkRepository.save(new PostBookmark(post, user)); + return PostBookmarkResponse.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 b01ca8f6..33d366e5 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -100,6 +100,8 @@ public enum ErrorCode { CATEGORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "POST_004", "이미 존재하는 카테고리입니다."), POST_ALREADY_LIKED(HttpStatus.CONFLICT, "POST_005", "이미 좋아요한 게시글입니다."), POST_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_006", "해당 게시글에 대한 좋아요 기록이 없습니다."), + BOOKMARK_ALREADY_EXISTS(HttpStatus.CONFLICT, "POST_007", "이미 북마크한 게시글입니다."), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_008", "해당 게시글에 대한 북마크 기록이 없습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않는 댓글입니다."), COMMENT_NO_PERMISSION(HttpStatus.FORBIDDEN, "COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다."), From 74a55c098d47e023b9dacaca0b000a5a30255fb0 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:33:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=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 --- .../controller/PostBookmarkController.java | 20 +++++++++--- .../repository/PostBookmarkRepository.java | 3 ++ .../post/service/PostBookmarkService.java | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java index 08d2b000..e6ccf169 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java @@ -7,16 +7,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -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}/bookmark") @RequiredArgsConstructor public class PostBookmarkController { - private final PostBookmarkService bookmarkService; private final PostBookmarkService postBookmarkService; // 게시글 북마크 @@ -32,4 +28,18 @@ public ResponseEntity> bookmarkPost( response )); } + + // 게시글 북마크 취소 + @DeleteMapping + public ResponseEntity> cancelBookmarkPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ) { + PostBookmarkResponse response = postBookmarkService.cancelBookmarkPost(postId, user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "게시글 북마크가 취소되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java index 350e0b11..b26b2a77 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java @@ -4,7 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface PostBookmarkRepository 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/PostBookmarkService.java b/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java index 896acea4..31323e62 100644 --- a/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java +++ b/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java @@ -50,4 +50,35 @@ public PostBookmarkResponse bookmarkPost(Long postId, Long userId) { postBookmarkRepository.save(new PostBookmark(post, user)); return PostBookmarkResponse.from(post); } + + /** + * 게시글 북마크 취소 서비스 + * 1. User 조회 + * 2. Post 조회 + * 3. PostBookmark 조회 + * 4. PostBookmark 삭제 및 bookmarkCount 감소 + * 5. PostBookmarkResponse 반환 + */ + public PostBookmarkResponse cancelBookmarkPost(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)); + + // PostBookmark 조회 + PostBookmark postBookmark = postBookmarkRepository.findByUserIdAndPostId(userId, postId) + .orElseThrow(() -> new CustomException(ErrorCode.BOOKMARK_NOT_FOUND)); + + // PostBookmark 삭제 + postBookmarkRepository.delete(postBookmark); + + // 북마크 수 감소 + post.decreaseBookmarkCount(); + + // 응답 반환 + return PostBookmarkResponse.from(post); + } } From ee6f2cbfbe2a330bfea2c06ec4597a75d717d197 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:50:04 +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 --- .../PostBookmarkControllerDocs.java | 4 + .../PostBookmarkControllerTest.java | 310 ++++++++++++++++++ .../service/PostBookmarkServiceTest.java | 141 ++++++++ 3 files changed, 455 insertions(+) create mode 100644 src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java create mode 100644 src/test/java/com/back/domain/board/controller/PostBookmarkControllerTest.java create mode 100644 src/test/java/com/back/domain/board/service/PostBookmarkServiceTest.java diff --git a/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java new file mode 100644 index 00000000..1e9a3cd6 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java @@ -0,0 +1,4 @@ +package com.back.domain.board.post.controller; + +public interface PostBookmarkControllerDocs { +} diff --git a/src/test/java/com/back/domain/board/controller/PostBookmarkControllerTest.java b/src/test/java/com/back/domain/board/controller/PostBookmarkControllerTest.java new file mode 100644 index 00000000..e04c3bac --- /dev/null +++ b/src/test/java/com/back/domain/board/controller/PostBookmarkControllerTest.java @@ -0,0 +1,310 @@ +package com.back.domain.board.controller; + +import com.back.domain.board.post.entity.Post; +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.domain.board.post.repository.PostRepository; +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.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class PostBookmarkControllerTest { + + @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 bookmarkPost_success() throws Exception { + User user = User.createUser("bookmarkUser", "bookmark@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); + + mvc.perform(post("/api/posts/{postId}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .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.bookmarkCount").value(1)); + } + + @Test + @DisplayName("게시글 북마크 실패 - 존재하지 않는 사용자 → 404 Not Found") + void bookmarkPost_userNotFound() throws Exception { + 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); + + mvc.perform(post("/api/posts/{postId}/bookmark", 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 bookmarkPost_postNotFound() throws Exception { + User user = User.createUser("bookmarkUser2", "bookmark2@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); + + mvc.perform(post("/api/posts/{postId}/bookmark", 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 bookmarkPost_alreadyBookmarked() throws Exception { + User user = User.createUser("bookmarkUser3", "bookmark3@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}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + mvc.perform(post("/api/posts/{postId}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("POST_007")) + .andExpect(jsonPath("$.message").value("이미 북마크한 게시글입니다.")); + } + + @Test + @DisplayName("게시글 북마크 실패 - 토큰 없음 → 401 Unauthorized") + void bookmarkPost_noToken() throws Exception { + 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); + + mvc.perform(post("/api/posts/{postId}/bookmark", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("게시글 북마크 실패 - 잘못된 요청 파라미터 → 400 Bad Request") + void bookmarkPost_badRequest() throws Exception { + User user = User.createUser("bookmarkUser", "bookmark@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); + + String accessToken = generateAccessToken(user); + + mvc.perform(post("/api/posts/{postId}/bookmark", "invalid") // invalid param + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } + + // ====================== 게시글 북마크 취소 ====================== + + @Test + @DisplayName("게시글 북마크 취소 성공 → 200 OK") + void cancelBookmarkPost_success() throws Exception { + 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}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + mvc.perform(delete("/api/posts/{postId}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .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.bookmarkCount").value(0)); + } + + @Test + @DisplayName("게시글 북마크 취소 실패 - 존재하지 않는 사용자 → 404 Not Found") + void cancelBookmarkPost_userNotFound() throws Exception { + 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); + + mvc.perform(delete("/api/posts/{postId}/bookmark", 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 cancelBookmarkPost_postNotFound() throws Exception { + 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); + + mvc.perform(delete("/api/posts/{postId}/bookmark", 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 cancelBookmarkPost_notFound() throws Exception { + 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); + + mvc.perform(delete("/api/posts/{postId}/bookmark", post.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_008")) + .andExpect(jsonPath("$.message").value("해당 게시글에 대한 북마크 기록이 없습니다.")); + } + + @Test + @DisplayName("게시글 북마크 취소 실패 - 토큰 없음 → 401 Unauthorized") + void cancelBookmarkPost_noToken() throws Exception { + 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); + + mvc.perform(delete("/api/posts/{postId}/bookmark", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("게시글 북마크 취소 실패 - 잘못된 요청 파라미터 → 400 Bad Request") + void cancelBookmarkPost_badRequest() throws Exception { + User user = User.createUser("bookmarkUser", "bookmark@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); + + String accessToken = generateAccessToken(user); + + mvc.perform(delete("/api/posts/{postId}/bookmark", "invalid") // invalid param + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON_400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); + } +} diff --git a/src/test/java/com/back/domain/board/service/PostBookmarkServiceTest.java b/src/test/java/com/back/domain/board/service/PostBookmarkServiceTest.java new file mode 100644 index 00000000..64934a95 --- /dev/null +++ b/src/test/java/com/back/domain/board/service/PostBookmarkServiceTest.java @@ -0,0 +1,141 @@ +package com.back.domain.board.service; + +import com.back.domain.board.post.dto.PostBookmarkResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; +import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.board.post.service.PostBookmarkService; +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 PostBookmarkServiceTest { + + @Autowired + private PostBookmarkService postBookmarkService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostBookmarkRepository postBookmarkRepository; + + @Autowired + private UserRepository userRepository; + + // ====================== 게시글 북마크 등록 테스트 ====================== + + @Test + @DisplayName("게시글 북마크 성공") + void bookmarkPost_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 + PostBookmarkResponse response = postBookmarkService.bookmarkPost(post.getId(), user.getId()); + + // then + assertThat(response.bookmarkCount()).isEqualTo(1); + assertThat(postBookmarkRepository.existsByUserIdAndPostId(user.getId(), post.getId())).isTrue(); + } + + @Test + @DisplayName("게시글 북마크 실패 - 존재하지 않는 게시글") + void bookmarkPost_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(() -> postBookmarkService.bookmarkPost(999L, user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("게시글 북마크 실패 - 이미 북마크한 경우") + void bookmarkPost_fail_alreadyBookmarked() { + // 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); + + postBookmarkRepository.save(new PostBookmark(post, user)); + + // when & then + assertThatThrownBy(() -> postBookmarkService.bookmarkPost(post.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BOOKMARK_ALREADY_EXISTS.getMessage()); + } + + // ====================== 게시글 북마크 취소 테스트 ====================== + + @Test + @DisplayName("게시글 북마크 취소 성공") + void cancelBookmarkPost_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); + + PostBookmark postBookmark = new PostBookmark(post, user); + postBookmarkRepository.save(postBookmark); + post.increaseBookmarkCount(); + + // when + PostBookmarkResponse response = postBookmarkService.cancelBookmarkPost(post.getId(), user.getId()); + + // then + assertThat(response.bookmarkCount()).isEqualTo(0); + assertThat(postBookmarkRepository.existsByUserIdAndPostId(user.getId(), post.getId())).isFalse(); + } + + @Test + @DisplayName("게시글 북마크 취소 실패 - 북마크 내역 없음") + void cancelBookmarkPost_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(() -> postBookmarkService.cancelBookmarkPost(post.getId(), user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BOOKMARK_NOT_FOUND.getMessage()); + } +} From 495dad9a2fd6d65b02bb94d7e5df6785b6332c60 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:52:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Feat:=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 --- .../controller/PostBookmarkController.java | 2 +- .../PostBookmarkControllerDocs.java | 249 ++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java index e6ccf169..260b611d 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java @@ -12,7 +12,7 @@ @RestController @RequestMapping("/api/posts/{postId}/bookmark") @RequiredArgsConstructor -public class PostBookmarkController { +public class PostBookmarkController implements PostBookmarkControllerDocs { private final PostBookmarkService postBookmarkService; // 게시글 북마크 diff --git a/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java index 1e9a3cd6..45c828c4 100644 --- a/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java @@ -1,4 +1,253 @@ package com.back.domain.board.post.controller; +import com.back.domain.board.post.dto.PostBookmarkResponse; +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 Bookmark API", description = "게시글 북마크 등록/취소 API") public interface PostBookmarkControllerDocs { + + @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, + "bookmarkCount": 5 + } + } + """)) + ), + @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_007", + "message": "이미 북마크한 게시글입니다.", + "data": null + } + """)) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """)) + ) + }) + ResponseEntity> bookmarkPost( + @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, + "bookmarkCount": 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": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """), + @ExampleObject(name = "북마크 내역 없음", value = """ + { + "success": false, + "code": "POST_008", + "message": "해당 게시글에 대한 북마크 기록이 없습니다.", + "data": null + } + """) + }) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """)) + ) + }) + ResponseEntity> cancelBookmarkPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails user + ); }