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..260b611d --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkController.java @@ -0,0 +1,45 @@ +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.*; + +@RestController +@RequestMapping("/api/posts/{postId}/bookmark") +@RequiredArgsConstructor +public class PostBookmarkController implements PostBookmarkControllerDocs { + 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 + )); + } + + // 게시글 북마크 취소 + @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/controller/PostBookmarkControllerDocs.java b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java new file mode 100644 index 00000000..45c828c4 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/controller/PostBookmarkControllerDocs.java @@ -0,0 +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 + ); +} 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..b26b2a77 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java @@ -0,0 +1,13 @@ +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; + +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 new file mode 100644 index 00000000..31323e62 --- /dev/null +++ b/src/main/java/com/back/domain/board/post/service/PostBookmarkService.java @@ -0,0 +1,84 @@ +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); + } + + /** + * 게시글 북마크 취소 서비스 + * 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); + } +} 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", "댓글 작성자만 수정/삭제할 수 있습니다."), 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()); + } +}