From 6d6db753a759bb7567bbc376787927b2d2cf29ad Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Mon, 10 Nov 2025 03:01:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[BE][FEAT]=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/PostController.java | 127 +++++----- .../controller/PostInteractionController.java | 78 ++++++ .../backend/board/dto/BoardRequest.java | 25 ++ .../backend/board/dto/PostRequest.java | 10 +- .../backend/board/dto/PostResponse.java | 14 +- .../sejongisc/backend/board/entity/Board.java | 35 +++ .../sejongisc/backend/board/entity/Post.java | 8 +- .../board/repository/BoardRepository.java | 9 + .../board/repository/PostRepository.java | 18 +- .../board/service/PostInteractionService.java | 182 ++++++++++++++ .../backend/board/service/PostService.java | 30 +-- .../board/service/PostServiceImpl.java | 234 +++++------------- .../backend/common/exception/ErrorCode.java | 6 +- .../board/service/PostServiceImplTest.java | 23 +- 14 files changed, 507 insertions(+), 292 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java create mode 100644 backend/src/main/java/org/sejongisc/backend/board/dto/BoardRequest.java create mode 100644 backend/src/main/java/org/sejongisc/backend/board/entity/Board.java create mode 100644 backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java create mode 100644 backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java index 8e09fcff..032a9598 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java @@ -1,34 +1,46 @@ package org.sejongisc.backend.board.controller; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.board.entity.BoardType; -import org.sejongisc.backend.board.entity.PostType; -import org.sejongisc.backend.board.dto.*; +import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.PostRequest; +import org.sejongisc.backend.board.dto.PostResponse; import org.sejongisc.backend.board.service.PostService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.springframework.data.domain.Page; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api/post") @Tag( - name = "게시글 및 댓글 API", - description = "게시글 및 댓글 작성, 수정, 삭제 관련 API 제공" + name = "게시판 및 게시물 API", + description = "게시판 및 게시물 작성, 수정, 삭제 관련 API 제공" ) public class PostController { private final PostService postService; // 게시글 작성 + @Operation( + summary = "게시물 작성", + description = "게시판 ID, 제목, 내용, 첨부파일을 포함한 게시물을 작성합니다." + ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createPost( @Valid @ModelAttribute PostRequest request, @@ -39,6 +51,12 @@ public ResponseEntity createPost( } // 게시글 수정 + @Operation( + summary = "게시물 수정", + description = "제목, 내용, 첨부파일을 포함한 게시물을 수정합니다." + + "첨부파일은 전체 파일 삭제 후 재저장 방식으로 이루어집니다." + + "게시판 종류는 수정할 수 없습니다." + ) @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updatePost( @Valid @ModelAttribute PostRequest request, @@ -50,6 +68,12 @@ public ResponseEntity updatePost( } // 게시글 삭제 + @Operation( + summary = "게시글 삭제", + description = "게시글 ID를 통해 게시글을 삭제합니다." + + "작성자 본인만 삭제할 수 있습니다." + + "관련 첨부파일 및 댓글 등도 함께 삭제됩니다." + ) @DeleteMapping("/{postId}") public void deletePost( @PathVariable UUID postId, @@ -58,25 +82,44 @@ public void deletePost( postService.deletePost(postId, userId); } - // 게시글 조회 (공지/일반) - @GetMapping + // 게시글 조회 + @Operation( + summary = "게시글 조회", + description = "게시판 ID를 통해 해당 게시판의 게시글 목록을 조회합니다." + + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 페이지 번호 0, 페이지 크기 20입니다." + ) + @GetMapping("/{boardId}") public ResponseEntity> getPosts( - @RequestParam BoardType boardType, + @PathVariable UUID boardId, @RequestParam(defaultValue = "0") int pageNumber, @RequestParam(defaultValue = "20") int pageSize) { - return ResponseEntity.ok(postService.getPosts(boardType, pageNumber, pageSize)); + return ResponseEntity.ok(postService.getPosts(boardId, pageNumber, pageSize)); } // 게시글 검색 + @Operation( + summary = "게시글 검색", + description = "게시판 ID와 키워드를 통해 해당 게시판의 게시글 목록을 검색합니다." + + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 페이지 번호 0, 페이지 크기 20입니다." + ) @GetMapping("/search") public ResponseEntity> searchPosts( + @RequestParam UUID boardId, @RequestParam String keyword, @RequestParam(defaultValue = "0") int pageNumber, @RequestParam(defaultValue = "20") int pageSize) { - return ResponseEntity.ok(postService.searchPosts(keyword, pageNumber, pageSize)); + return ResponseEntity.ok(postService.searchPosts(boardId, keyword, pageNumber, pageSize)); } // 게시물 상세 조회 + @Operation( + summary = "게시물 상세 조회", + description = "게시물 ID를 통해 게시물의 상세 정보를 조회합니다." + + "댓글에 대해서도 페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 댓글 페이지 번호 0, 댓글 페이지 크기 20입니다." + ) @GetMapping("/{postId}") public ResponseEntity getPostDetail( @PathVariable UUID postId, @@ -86,52 +129,16 @@ public ResponseEntity getPostDetail( return ResponseEntity.ok(response); } - // 좋아요 토글 - @PostMapping("/{postId}/like") - public ResponseEntity toggleLike( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.toggleLike(postId, userId); + // 게시판 생성 + @Operation( + summary = "게시판 생성", + description = "게시판 이름과 상위 게시판 ID를 포함한 새로운 게시판을 생성합니다." + + "상위 게시판의 ID가 null 이면 최상위 게시판으로 생성됩니다." + ) + @PostMapping("/board") + public ResponseEntity createBoard( + @RequestBody @Valid BoardRequest request) { + postService.createBoard(request); return ResponseEntity.ok().build(); } - - // 북마크 토글 - @PostMapping("/{postId}/bookmark") - public ResponseEntity toggleBookmark( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.toggleBookmark(postId, userId); - return ResponseEntity.ok().build(); - } - - // 댓글 작성 - @PostMapping("/{postId}/comment") - public ResponseEntity createComment( - @RequestBody CommentRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.createComment(request, userId); - return ResponseEntity.ok().build(); - } - - // 댓글 수정 - @PutMapping("/comment/{commentId}") - public void updateComment( - @PathVariable UUID commentId, - @RequestBody CommentRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.updateComment(request, commentId, userId); - } - - // 댓글 삭제 - @DeleteMapping("/comment/{commentId}") - public void deleteComment( - @PathVariable UUID commentId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.deleteComment(commentId, userId); - } } diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java new file mode 100644 index 00000000..66797fca --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java @@ -0,0 +1,78 @@ +package org.sejongisc.backend.board.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.service.PostInteractionService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/post") +@Tag( + name = "게시물 기능 관련 API", + description = "댓글 작성, 수정, 삭제 및 좋아요, 북마크 API 제공" +) +public class PostInteractionController { + + private final PostInteractionService postInteractionService; + + // 좋아요 토글 + @PostMapping("/{postId}/like") + public ResponseEntity toggleLike( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.toggleLike(postId, userId); + return ResponseEntity.ok().build(); + } + + // 북마크 토글 + @PostMapping("/{postId}/bookmark") + public ResponseEntity toggleBookmark( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.toggleBookmark(postId, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 작성 + @PostMapping("/{postId}/comment") + public ResponseEntity createComment( + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.createComment(request, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 수정 + @PutMapping("/comment/{commentId}") + public void updateComment( + @PathVariable UUID commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.updateComment(request, commentId, userId); + } + + // 댓글 삭제 + @DeleteMapping("/comment/{commentId}") + public void deleteComment( + @PathVariable UUID commentId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.deleteComment(commentId, userId); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/BoardRequest.java b/backend/src/main/java/org/sejongisc/backend/board/dto/BoardRequest.java new file mode 100644 index 00000000..f69b7fa0 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/BoardRequest.java @@ -0,0 +1,25 @@ +package org.sejongisc.backend.board.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class BoardRequest { + + @Schema(description = "게시판 이름") + private String boardName; + + @Schema(description = "상위 게시판 ID (없으면 null)") + private UUID parentBoardId; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java index 37c7663a..e5134d32 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java @@ -3,14 +3,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.List; +import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; -import org.sejongisc.backend.board.entity.BoardType; -import org.sejongisc.backend.board.entity.PostType; import org.springframework.web.multipart.MultipartFile; @ToString @@ -21,8 +20,8 @@ @Builder public class PostRequest { - @NotNull(message = "게시판 타입을 선택해주세요.") - private BoardType boardType; + @NotNull(message = "게시판을 선택해주세요.") + private UUID boardId; @NotBlank(message = "제목은 필수 항목입니다.") private String title; @@ -30,8 +29,5 @@ public class PostRequest { @NotBlank(message = "내용은 필수 항목입니다.") private String content; - @NotNull(message = "게시글 타입을 선택해주세요.") - private PostType postType; - private List files; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java index fad77c17..013b4d3e 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java @@ -1,18 +1,15 @@ package org.sejongisc.backend.board.dto; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; -import org.sejongisc.backend.board.entity.BoardType; -import org.sejongisc.backend.board.entity.Post; -import org.sejongisc.backend.board.entity.PostType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; +import org.sejongisc.backend.board.entity.Board; import org.sejongisc.backend.user.entity.User; import org.springframework.data.domain.Page; @@ -25,11 +22,10 @@ public class PostResponse { private UUID postId; - private BoardType boardType; + private Board board; private User user; private String title; private String content; - private PostType postType; private Integer bookmarkCount; private Integer likeCount; private Integer commentCount; diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java new file mode 100644 index 00000000..bf231402 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java @@ -0,0 +1,35 @@ +package org.sejongisc.backend.board.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Board extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false) + private UUID boardId; + + private String boardName; + + @ManyToOne(fetch = FetchType.LAZY) + private Board parentBoard; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java index 26254d61..fb015498 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java @@ -28,8 +28,8 @@ public class Post extends BasePostgresEntity { private User user; // 게시판 타입 - @Enumerated(EnumType.STRING) - private BoardType boardType; + @ManyToOne(fetch = FetchType.LAZY) + private Board board; // 제목 @Column(nullable = false) @@ -39,10 +39,6 @@ public class Post extends BasePostgresEntity { @Column(columnDefinition = "TEXT", nullable = false) private String content; - // 게시글 타입 - @Enumerated(EnumType.STRING) - private PostType postType; - // 북마크 수 @Builder.Default private Integer bookmarkCount = 0; diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java new file mode 100644 index 00000000..111e1b4b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java @@ -0,0 +1,9 @@ +package org.sejongisc.backend.board.repository; + +import java.util.UUID; +import org.sejongisc.backend.board.entity.Board; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BoardRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java index 7ab8316a..51b7cb3b 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java @@ -1,17 +1,19 @@ package org.sejongisc.backend.board.repository; -import org.sejongisc.backend.board.entity.BoardType; +import java.util.UUID; +import org.sejongisc.backend.board.entity.Board; import org.sejongisc.backend.board.entity.Post; -import org.sejongisc.backend.board.entity.PostType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.UUID; - public interface PostRepository extends JpaRepository { - Page findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( - String titleKeyword, String contentKeyword, Pageable pageable); - Page findAllByBoardType(BoardType boardType, Pageable pageable); + + Page findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + String titleKeyword, String contentKeyword, Pageable pageable); + + Page findAllByBoard(Board board, Pageable pageable); + + Page findAllByBoardAndTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + Board board, String titleKeyword, String contentKeyword, Pageable pageable); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java new file mode 100644 index 00000000..271ee9aa --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java @@ -0,0 +1,182 @@ +package org.sejongisc.backend.board.service; + +import jakarta.persistence.OptimisticLockException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.entity.Comment; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostBookmark; +import org.sejongisc.backend.board.entity.PostLike; +import org.sejongisc.backend.board.repository.CommentRepository; +import org.sejongisc.backend.board.repository.PostBookmarkRepository; +import org.sejongisc.backend.board.repository.PostLikeRepository; +import org.sejongisc.backend.board.repository.PostRepository; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PostInteractionService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + private final PostBookmarkRepository postBookmarkRepository; + + // 댓글 작성 + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void createComment(CommentRequest request, UUID userId) { + // 게시글 조회 + Post post = postRepository.findById(request.getPostId()) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 작성자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // comment 엔티티 저장 + Comment comment = Comment.builder() + .post(post) + .user(user) + .content(request.getContent()) + .build(); + + commentRepository.save(comment); + + // 게시글의 댓글 수 1 증가 + post.setCommentCount(post.getCommentCount() + 1); + } + + // 댓글 수정 + @Transactional + public void updateComment(CommentRequest request, UUID commentId, UUID userId) { + // comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 작성자 확인 + if (!comment.getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); + } + + // 내용 업데이트 + comment.setContent(request.getContent()); + } + + // 댓글 삭제 + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void deleteComment(UUID commentId, UUID userId) { + // comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 관리자 확인 + boolean isAdmin = userRepository.findById(userId) + .map(user -> user.getRole() == Role.PRESIDENT || user.getRole() == Role.VICE_PRESIDENT) + .orElse(false); + + // 작성자 확인 (관리자는 통과) + if (!comment.getUser().getUserId().equals(userId) && !isAdmin) { + throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); + } + + // 게시글의 댓글 수 1 감소 + Post post = postRepository.findById(comment.getPost().getPostId()) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + post.setCommentCount(post.getCommentCount() - 1); + + // comment 삭제 + commentRepository.delete(comment); + } + + // 좋아요 등록/삭제 + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void toggleLike(UUID postId, UUID userId) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이미 좋아요를 눌렀는지 확인 + Optional existingLike = postLikeRepository.findByPostPostIdAndUserUserId(postId, userId); + + if (existingLike.isPresent()) { + // 좋아요가 이미 있으면 -> 삭제 (좋아요 취소) + postLikeRepository.delete(existingLike.get()); + post.setLikeCount(post.getLikeCount() - 1); // Post 엔티티 카운트 감소 + } else { + // 좋아요가 없으면 -> 생성 (좋아요) + PostLike newLike = PostLike.builder() + .post(post) + .user(user) + .build(); + postLikeRepository.save(newLike); + post.setLikeCount(post.getLikeCount() + 1); // Post 엔티티 카운트 증가 + } + } + + // 북마크 등록/삭제 + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void toggleBookmark(UUID postId, UUID userId) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이미 북마크를 했는지 확인 + Optional existingBookmark = postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId); + + if (existingBookmark.isPresent()) { + // 북마크가 이미 있으면 -> 삭제 (북마크 취소) + postBookmarkRepository.delete(existingBookmark.get()); + post.setBookmarkCount(post.getBookmarkCount() - 1); // Post 엔티티 카운트 감소 + } else { + // 북마크가 없으면 -> 생성 (북마크) + PostBookmark newBookmark = PostBookmark.builder() + .post(post) + .user(user) + .build(); + postBookmarkRepository.save(newBookmark); + post.setBookmarkCount(post.getBookmarkCount() + 1); // Post 엔티티 카운트 증가 + } + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java index d3474ba0..8e132e06 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java @@ -1,13 +1,11 @@ package org.sejongisc.backend.board.service; -import org.sejongisc.backend.board.dto.CommentRequest; +import java.util.UUID; +import org.sejongisc.backend.board.dto.BoardRequest; import org.sejongisc.backend.board.dto.PostRequest; import org.sejongisc.backend.board.dto.PostResponse; -import org.sejongisc.backend.board.entity.BoardType; import org.springframework.data.domain.Page; -import java.util.UUID; - public interface PostService { // 게시물 작성 @@ -19,27 +17,15 @@ public interface PostService { // 게시물 삭제 void deletePost(UUID postId, UUID userId); - // 게시물 조회 (전체) - Page getPosts(BoardType boardType, int pageNumber, int pageSize); + // 게시물 조회 + Page getPosts(UUID boardId, int pageNumber, int pageSize); - // 게시물 검색 (제목/내용) - Page searchPosts(String keyword, int pageNumber, int pageSize); + // 게시물 검색 + Page searchPosts(UUID boardId, String keyword, int pageNumber, int pageSize); // 게시물 상세 조회 PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize); - // 댓글 작성 - void createComment(CommentRequest request, UUID userId); - - // 댓글 수정 - void updateComment(CommentRequest request, UUID commentId, UUID userId); - - // 댓글 삭제 - void deleteComment(UUID commentId, UUID userId); - - // 좋아요 - void toggleLike(UUID postId, UUID userId); - - // 북마크 - void toggleBookmark(UUID postId, UUID userId); + // 게시판 생성 + void createBoard(BoardRequest request); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index e40c9e4a..a6ff42d5 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -1,32 +1,39 @@ package org.sejongisc.backend.board.service; -import jakarta.persistence.OptimisticLockException; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.board.entity.*; -import org.sejongisc.backend.board.dto.*; -import org.sejongisc.backend.board.repository.*; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.CommentResponse; +import org.sejongisc.backend.board.dto.PostAttachmentResponse; +import org.sejongisc.backend.board.dto.PostRequest; +import org.sejongisc.backend.board.dto.PostResponse; +import org.sejongisc.backend.board.entity.Board; +import org.sejongisc.backend.board.entity.Comment; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostAttachment; +import org.sejongisc.backend.board.repository.BoardRepository; +import org.sejongisc.backend.board.repository.CommentRepository; +import org.sejongisc.backend.board.repository.PostAttachmentRepository; +import org.sejongisc.backend.board.repository.PostBookmarkRepository; +import org.sejongisc.backend.board.repository.PostLikeRepository; +import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; -import org.springframework.orm.ObjectOptimisticLockingFailureException; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import java.util.*; import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor -@Transactional +@Slf4j public class PostServiceImpl implements PostService { private final UserRepository userRepository; @@ -35,19 +42,28 @@ public class PostServiceImpl implements PostService { private final PostLikeRepository postLikeRepository; private final PostBookmarkRepository postBookmarkRepository; private final PostAttachmentRepository postAttachmentRepository; + private final BoardRepository boardRepository; private final FileUploadService fileUploadService; // 게시물 작성 @Override @Transactional public void savePost(PostRequest request, UUID userId) { + Board board = boardRepository.findById(request.getBoardId()) + .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); + + // 최상위 게시판일 경우 + if (board.getParentBoard() == null) { + log.error("최상위 게시판에는 게시물을 작성할 수 없습니다. boardId: {}", request.getBoardId()); + throw new CustomException(ErrorCode.INVALID_BOARD_TYPE); + } + Post post = Post.builder() .user(userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND))) - .boardType(request.getBoardType()) + .board(board) .title(request.getTitle()) .content(request.getContent()) - .postType(request.getPostType()) .build(); post = postRepository.save(post); @@ -85,7 +101,6 @@ public void updatePost(PostRequest request, UUID postId, UUID userId) { post.setTitle(request.getTitle()); post.setContent(request.getContent()); - post.setPostType(request.getPostType()); // 기존 파일 조회 및 삭제 List existingAttachments = postAttachmentRepository.findAllByPostPostId(postId); @@ -151,35 +166,43 @@ public void deletePost(UUID postId, UUID userId) { postRepository.delete(post); } - // 게시물 조회 (전체 | 금융 IT | 자산 운용) + // 게시물 조회 (해당 게시판의 게시물) @Override @Transactional(readOnly = true) - public Page getPosts(BoardType boardType, int pageNumber, int pageSize) { + public Page getPosts(UUID boardId, int pageNumber, int pageSize) { Pageable pageable = PageRequest.of( pageNumber, pageSize, Sort.by(Direction.DESC, "createdDate") ); - // 게시판 타입에 따른 게시물 조회 - Page posts = postRepository.findAllByBoardType(boardType, pageable); + // 게시판 조회 + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); + + // 해당 게시판의 게시물 조회 + Page posts = postRepository.findAllByBoard(board, pageable); return posts.map(this::mapToPostResponse); } // 게시물 검색 (제목/내용) - @Transactional(readOnly = true) @Override - public Page searchPosts(String keyword, int pageNumber, int pageSize) { + @Transactional(readOnly = true) + public Page searchPosts(UUID boardId, String keyword, int pageNumber, int pageSize) { Pageable pageable = PageRequest.of( pageNumber, pageSize, Sort.by(Direction.DESC, "createdDate") ); + // 게시판 조회 + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); + // 해당 키워드가 들어간 게시물 검색 - Page posts = postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( - keyword, keyword, pageable); + Page posts = postRepository.findAllByBoardAndTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + board, keyword, keyword, pageable); return posts.map(this::mapToPostResponse); } @@ -214,11 +237,10 @@ public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { // PostResponse DTO를 직접 빌드하여 반환 return PostResponse.builder() .postId(post.getPostId()) - .boardType(post.getBoardType()) + .board(post.getBoard()) .user(post.getUser()) .title(post.getTitle()) .content(post.getContent()) - .postType(post.getPostType()) .bookmarkCount(post.getBookmarkCount()) .likeCount(post.getLikeCount()) .commentCount(post.getCommentCount()) @@ -229,164 +251,38 @@ public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { .build(); } - // 댓글 작성 - @Override - @Transactional - @Retryable( - value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, - maxAttempts = 5, - backoff = @Backoff(delay = 100) - ) - public void createComment(CommentRequest request, UUID userId) { - // 게시글 조회 - Post post = postRepository.findById(request.getPostId()) - .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - - // 작성자 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // comment 엔티티 저장 - Comment comment = Comment.builder() - .post(post) - .user(user) - .content(request.getContent()) - .build(); - - commentRepository.save(comment); - - // 게시글의 댓글 수 1 증가 - post.setCommentCount(post.getCommentCount() + 1); - } - - // 댓글 수정 - @Override - @Transactional - public void updateComment(CommentRequest request, UUID commentId, UUID userId) { - // comment 조회 - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); - - // 작성자 확인 - if (!comment.getUser().getUserId().equals(userId)) { - throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); - } - - // 내용 업데이트 - comment.setContent(request.getContent()); - } - - // 댓글 삭제 - @Override - @Transactional - @Retryable( - value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, - maxAttempts = 5, - backoff = @Backoff(delay = 100) - ) - public void deleteComment(UUID commentId, UUID userId) { - // comment 조회 - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); - - // 관리자 확인 - boolean isAdmin = userRepository.findById(userId) - .map(user -> user.getRole() == Role.PRESIDENT || user.getRole() == Role.VICE_PRESIDENT) - .orElse(false); - - // 작성자 확인 (관리자는 통과) - if (!comment.getUser().getUserId().equals(userId) && !isAdmin) { - throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); - } - - // 게시글의 댓글 수 1 감소 - Post post = postRepository.findById(comment.getPost().getPostId()) - .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - - post.setCommentCount(post.getCommentCount() - 1); - - // comment 삭제 - commentRepository.delete(comment); - } - - // 좋아요 등록/삭제 - @Override + // 게시판 생성 @Transactional - @Retryable( - value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, - maxAttempts = 5, - backoff = @Backoff(delay = 100) - ) - public void toggleLike(UUID postId, UUID userId) { - // 게시물 조회 - Post post = postRepository.findById(postId) - .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - - // 유저 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // 이미 좋아요를 눌렀는지 확인 - Optional existingLike = postLikeRepository.findByPostPostIdAndUserUserId(postId, userId); - - if (existingLike.isPresent()) { - // 좋아요가 이미 있으면 -> 삭제 (좋아요 취소) - postLikeRepository.delete(existingLike.get()); - post.setLikeCount(post.getLikeCount() - 1); // Post 엔티티 카운트 감소 - } else { - // 좋아요가 없으면 -> 생성 (좋아요) - PostLike newLike = PostLike.builder() - .post(post) - .user(user) + public void createBoard(BoardRequest request) { + Board board; + // 하위 게시판인 경우 + if (request.getParentBoardId() != null) { + Board parentBoard = Board.builder() + .boardId(request.getParentBoardId()) .build(); - postLikeRepository.save(newLike); - post.setLikeCount(post.getLikeCount() + 1); // Post 엔티티 카운트 증가 - } - } - - // 북마크 등록/삭제 - @Override - @Transactional - @Retryable( - value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, - maxAttempts = 5, - backoff = @Backoff(delay = 100) - ) - public void toggleBookmark(UUID postId, UUID userId) { - // 게시물 조회 - Post post = postRepository.findById(postId) - .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - - // 유저 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - // 이미 북마크를 했는지 확인 - Optional existingBookmark = postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId); - - if (existingBookmark.isPresent()) { - // 북마크가 이미 있으면 -> 삭제 (북마크 취소) - postBookmarkRepository.delete(existingBookmark.get()); - post.setBookmarkCount(post.getBookmarkCount() - 1); // Post 엔티티 카운트 감소 + board = Board.builder() + .boardName(request.getBoardName()) + .parentBoard(parentBoard) + .build(); } else { - // 북마크가 없으면 -> 생성 (북마크) - PostBookmark newBookmark = PostBookmark.builder() - .post(post) - .user(user) + // 상위 게시판인 경우 + board = Board.builder() + .boardName(request.getBoardName()) + .parentBoard(null) .build(); - postBookmarkRepository.save(newBookmark); - post.setBookmarkCount(post.getBookmarkCount() + 1); // Post 엔티티 카운트 증가 } + + boardRepository.save(board); } private PostResponse mapToPostResponse(Post post) { return PostResponse.builder() .postId(post.getPostId()) .user(post.getUser()) - .boardType(post.getBoardType()) + .board(post.getBoard()) .title(post.getTitle()) .content(post.getContent()) - .postType(post.getPostType()) .bookmarkCount(post.getBookmarkCount()) .likeCount(post.getLikeCount()) .commentCount(post.getCommentCount()) diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index f38073a5..5b5fbc6c 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -82,7 +82,11 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."), - INVALID_COMMENT_OWNER(HttpStatus.FORBIDDEN, "댓글 수정/삭제 권한이 없습니다."); + INVALID_COMMENT_OWNER(HttpStatus.FORBIDDEN, "댓글 수정/삭제 권한이 없습니다."), + + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시판을 찾을 수 없습니다."), + + INVALID_BOARD_TYPE(HttpStatus.NOT_FOUND, "상위 게시판에는 글을 작성할 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java index 5b1bb693..990d8eac 100644 --- a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java @@ -42,6 +42,9 @@ class PostServiceImplTest { @InjectMocks PostServiceImpl postService; + @InjectMocks + PostInteractionService postInteractionService; + UUID userId; User user; @@ -284,7 +287,7 @@ void createComment_increaseCount() { req.setPostId(postId); req.setContent("hi"); - postService.createComment(req, userId); + postInteractionService.createComment(req, userId); verify(commentRepository).save(any(Comment.class)); assertThat(post.getCommentCount()).isEqualTo(1); @@ -301,7 +304,7 @@ void updateComment_success() { when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - postService.updateComment(req, commentId, userId); + postInteractionService.updateComment(req, commentId, userId); assertThat(comment.getContent()).isEqualTo("new content"); } @@ -318,7 +321,7 @@ void updateComment_notOwner_throws() { when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - assertThatThrownBy(() -> postService.updateComment(req, commentId, userId)) + assertThatThrownBy(() -> postInteractionService.updateComment(req, commentId, userId)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); } @@ -337,7 +340,7 @@ void deleteComment_ownerOrAdmin() { when(userRepository.findById(userId)).thenReturn(Optional.of(user)); when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - postService.deleteComment(commentId, userId); + postInteractionService.deleteComment(commentId, userId); verify(commentRepository).delete(comment); assertThat(post.getCommentCount()).isEqualTo(2); @@ -353,7 +356,7 @@ void deleteComment_ownerOrAdmin() { when(postRepository.findById(postId)).thenReturn(Optional.of(post)); post.setCommentCount(5); - postService.deleteComment(commentId, admin.getUserId()); + postInteractionService.deleteComment(commentId, admin.getUserId()); verify(commentRepository).delete(othersComment); assertThat(post.getCommentCount()).isEqualTo(4); } @@ -370,7 +373,7 @@ void deleteComment_notOwnerOrAdmin_throws() { when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - assertThatThrownBy(() -> postService.deleteComment(commentId, userId)) + assertThatThrownBy(() -> postInteractionService.deleteComment(commentId, userId)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); @@ -388,7 +391,7 @@ void toggleLike_add() { when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) .thenReturn(Optional.empty()); - postService.toggleLike(postId, userId); + postInteractionService.toggleLike(postId, userId); verify(postLikeRepository).save(argThat(l -> l.getPost().getPostId().equals(postId) && l.getUser().getUserId().equals(userId))); @@ -406,7 +409,7 @@ void toggleLike_remove() { when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) .thenReturn(Optional.of(like)); - postService.toggleLike(postId, userId); + postInteractionService.toggleLike(postId, userId); verify(postLikeRepository).delete(like); assertThat(post.getLikeCount()).isEqualTo(1); @@ -422,7 +425,7 @@ void toggleBookmark_add_and_remove() { when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) .thenReturn(Optional.empty()); - postService.toggleBookmark(postId, userId); + postInteractionService.toggleBookmark(postId, userId); verify(postBookmarkRepository).save(argThat(b -> b.getPost().getPostId().equals(postId) && b.getUser().getUserId().equals(userId))); assertThat(post.getBookmarkCount()).isEqualTo(1); @@ -432,7 +435,7 @@ void toggleBookmark_add_and_remove() { when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) .thenReturn(Optional.of(existingBookmark)); - postService.toggleBookmark(postId, userId); + postInteractionService.toggleBookmark(postId, userId); verify(postBookmarkRepository).delete(existingBookmark); assertThat(post.getBookmarkCount()).isEqualTo(0); From d4eec6837beef077689ce85a4329262ff6c3fe6c Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Mon, 10 Nov 2025 19:14:36 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[BE][FEAT]=EB=8C=80=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/PostController.java | 10 +++-- .../backend/board/dto/CommentRequest.java | 4 ++ .../backend/board/dto/CommentResponse.java | 22 +++++++++-- .../sejongisc/backend/board/entity/Board.java | 8 ++++ .../backend/board/entity/Comment.java | 25 ++++++++++-- .../sejongisc/backend/board/entity/Post.java | 20 ++++++++-- .../backend/board/entity/PostAttachment.java | 20 +++++++--- .../backend/board/entity/PostBookmark.java | 18 +++++++-- .../backend/board/entity/PostLike.java | 18 +++++++-- .../board/repository/CommentRepository.java | 4 ++ .../board/service/PostInteractionService.java | 38 +++++++++++++++++-- .../backend/board/service/PostService.java | 2 +- .../board/service/PostServiceImpl.java | 28 +++++++++++--- .../backend/common/exception/ErrorCode.java | 6 ++- 14 files changed, 184 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java index 032a9598..fdc48274 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java @@ -89,9 +89,9 @@ public void deletePost( + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + "기본값은 페이지 번호 0, 페이지 크기 20입니다." ) - @GetMapping("/{boardId}") + @GetMapping public ResponseEntity> getPosts( - @PathVariable UUID boardId, + @RequestParam UUID boardId, @RequestParam(defaultValue = "0") int pageNumber, @RequestParam(defaultValue = "20") int pageSize) { return ResponseEntity.ok(postService.getPosts(boardId, pageNumber, pageSize)); @@ -137,8 +137,10 @@ public ResponseEntity getPostDetail( ) @PostMapping("/board") public ResponseEntity createBoard( - @RequestBody @Valid BoardRequest request) { - postService.createBoard(request); + @RequestBody @Valid BoardRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.createBoard(request, userId); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java index d6a5099c..b54055ea 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java @@ -1,5 +1,6 @@ package org.sejongisc.backend.board.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.UUID; @@ -23,4 +24,7 @@ public class CommentRequest { @NotBlank(message = "댓글 내용은 필수 항목입니다.") private String content; + + @Schema(description = "부모 댓글 ID (대댓글인 경우에만 필요)") + private UUID parentCommentId; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java index 7bcf5c89..3e8f4092 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java @@ -1,15 +1,16 @@ package org.sejongisc.backend.board.dto; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.UUID; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.sejongisc.backend.board.entity.Comment; +import org.sejongisc.backend.user.entity.User; @ToString @AllArgsConstructor @@ -19,18 +20,33 @@ @Builder public class CommentResponse { private UUID commentId; + private User user; private UUID postId; private String content; private LocalDateTime createdDate; private LocalDateTime updatedDate; + private List replies; public static CommentResponse of(Comment comment) { return CommentResponse.builder() .commentId(comment.getCommentId()) + .user(comment.getUser()) + .postId(comment.getPost().getPostId()) + .content(comment.getContent()) + .createdDate(comment.getCreatedDate()) + .updatedDate(comment.getUpdatedDate()) + .build(); + } + + public static CommentResponse of(Comment comment, List replies) { + return CommentResponse.builder() + .commentId(comment.getCommentId()) + .user(comment.getUser()) .postId(comment.getPost().getPostId()) .content(comment.getContent()) .createdDate(comment.getCreatedDate()) .updatedDate(comment.getUpdatedDate()) + .replies(replies) .build(); } } diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java index bf231402..74cae301 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java @@ -1,11 +1,13 @@ package org.sejongisc.backend.board.entity; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import java.util.UUID; import lombok.AllArgsConstructor; @@ -14,6 +16,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; @Entity @Getter @@ -21,6 +24,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class Board extends BasePostgresEntity { @Id @@ -31,5 +35,9 @@ public class Board extends BasePostgresEntity { private String boardName; @ManyToOne(fetch = FetchType.LAZY) + private User createdBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_board_id") private Board parentBoard; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java index 7264740c..34adfc46 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java @@ -1,10 +1,20 @@ package org.sejongisc.backend.board.entity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import org.sejongisc.backend.user.entity.User; @@ -14,6 +24,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class Comment extends BasePostgresEntity { @Id @@ -21,11 +32,17 @@ public class Comment extends BasePostgresEntity { private UUID commentId; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") private Post post; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private User user; @Column(columnDefinition = "TEXT", nullable = false) private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java index fb015498..610aa1a1 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java @@ -1,11 +1,21 @@ package org.sejongisc.backend.board.entity; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.GenericGenerator; - +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Version; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import org.sejongisc.backend.user.entity.User; @@ -25,10 +35,12 @@ public class Post extends BasePostgresEntity { // 작성자 @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private User user; // 게시판 타입 @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") private Board board; // 제목 diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java index 30b534a1..95806627 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java @@ -1,12 +1,19 @@ package org.sejongisc.backend.board.entity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.util.UUID; -import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; -import org.sejongisc.backend.user.entity.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Getter @@ -21,6 +28,7 @@ public class PostAttachment { private UUID postAttachmentId; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") private Post post; @Column(nullable = false) diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java index f0fea243..bf4d9eed 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java @@ -1,10 +1,18 @@ package org.sejongisc.backend.board.entity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import org.sejongisc.backend.user.entity.User; @@ -21,8 +29,10 @@ public class PostBookmark extends BasePostgresEntity { private UUID postBookmarkId; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") private Post post; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private User user; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java index 762d7e0f..4ee55fa3 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java @@ -1,10 +1,18 @@ package org.sejongisc.backend.board.entity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import org.sejongisc.backend.user.entity.User; @@ -21,8 +29,10 @@ public class PostLike extends BasePostgresEntity { private UUID postLikeId; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") private Post post; @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private User user; } diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java index 458e586c..7cbbc8d7 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java @@ -19,4 +19,8 @@ public interface CommentRepository extends JpaRepository { @Transactional void deleteAllByPostPostId(UUID postId); + + List findByParentComment(Comment parentComment); + + Page findAllByPostPostIdAndParentCommentIsNull(UUID postId, Pageable pageable); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java index 271ee9aa..4bd0862b 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java @@ -1,6 +1,7 @@ package org.sejongisc.backend.board.service; import jakarta.persistence.OptimisticLockException; +import java.util.List; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -51,11 +52,28 @@ public void createComment(CommentRequest request, UUID userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 부모 댓글 조회 (대댓글인 경우) + Comment parentComment = null; + if (request.getParentCommentId() != null) { + parentComment = commentRepository.findById(request.getParentCommentId()) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 부모 댓글이 해당 게시글에 속하는지 확인 + if (!parentComment.getPost().getPostId().equals(post.getPostId())) { + throw new CustomException(ErrorCode.INVALID_PARENT_COMMENT); + } + + if (parentComment.getParentComment() != null) { + throw new CustomException(ErrorCode.ALREADY_CHILD_COMMENT); + } + } + // comment 엔티티 저장 Comment comment = Comment.builder() .post(post) .user(user) .content(request.getContent()) + .parentComment(parentComment) .build(); commentRepository.save(comment); @@ -102,14 +120,28 @@ public void deleteComment(UUID commentId, UUID userId) { throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); } - // 게시글의 댓글 수 1 감소 + // 게시글 조회 Post post = postRepository.findById(comment.getPost().getPostId()) .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); - post.setCommentCount(post.getCommentCount() - 1); + // 자식 댓글 조회 + List childComments = commentRepository.findByParentComment(comment); - // comment 삭제 + int totalDeletedCount = 0; // 삭제할 총 개수 + + if (!childComments.isEmpty()) { + // 자식 댓글 일괄 삭제 (쿼리 1번) + commentRepository.deleteAll(childComments); + + totalDeletedCount = childComments.size(); // 자식 댓글 개수만큼 카운트 + } + + // 부모 댓글(본인) 삭제 commentRepository.delete(comment); + totalDeletedCount += 1; // 본인 개수 추가 + + // Post 카운트 업데이트 + post.setCommentCount(post.getCommentCount() - totalDeletedCount); } // 좋아요 등록/삭제 diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java index 8e132e06..326e443b 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java @@ -27,5 +27,5 @@ public interface PostService { PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize); // 게시판 생성 - void createBoard(BoardRequest request); + void createBoard(BoardRequest request, UUID userId); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index a6ff42d5..b3b3d136 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -22,6 +22,7 @@ import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -222,11 +223,23 @@ public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { Sort.by(Sort.Direction.ASC, "createdDate") ); - // 해당 게시물의 댓글 목록을 '페이징'하여 조회 - Page comments = commentRepository.findAllByPostPostId(postId, pageable); + // 부모 댓글만 페이징하여 조회 + Page parentComments = commentRepository + .findAllByPostPostIdAndParentCommentIsNull(postId, pageable); - // Page -> Page DTO로 변환 - Page commentResponses = comments.map(CommentResponse::of); + // 부모 댓글을 CommentResponse DTO로 변환 + Page commentResponses = parentComments.map(parent -> { + // 해당 부모 댓글의 자식 댓글 목록을 조회 + List childComments = commentRepository.findByParentComment(parent); + + // 자식 댓글 목록을 CommentResponse DTO 리스트로 변환 + List replyResponses = childComments.stream() + .map(CommentResponse::of) + .toList(); + + // 부모 댓글 DTO를 생성하며, 자식 DTO 리스트를 주입 + return CommentResponse.of(parent, replyResponses); + }); // 첨부 파일 조회 List attachmentResponses = postAttachmentRepository.findAllByPostPostId(postId) @@ -253,7 +266,10 @@ public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { // 게시판 생성 @Transactional - public void createBoard(BoardRequest request) { + public void createBoard(BoardRequest request, UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + Board board; // 하위 게시판인 경우 if (request.getParentBoardId() != null) { @@ -263,12 +279,14 @@ public void createBoard(BoardRequest request) { board = Board.builder() .boardName(request.getBoardName()) + .createdBy(user) .parentBoard(parentBoard) .build(); } else { // 상위 게시판인 경우 board = Board.builder() .boardName(request.getBoardName()) + .createdBy(user) .parentBoard(null) .build(); } diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 5b5fbc6c..53058874 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -84,9 +84,13 @@ public enum ErrorCode { INVALID_COMMENT_OWNER(HttpStatus.FORBIDDEN, "댓글 수정/삭제 권한이 없습니다."), + INVALID_PARENT_COMMENT(HttpStatus.BAD_REQUEST, "부모 댓글이 해당 게시판에 속해 있지 않습니다."), + + ALREADY_CHILD_COMMENT(HttpStatus.BAD_REQUEST, "대댓글에는 다시 대댓글을 작성할 수 없습니다."), + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시판을 찾을 수 없습니다."), - INVALID_BOARD_TYPE(HttpStatus.NOT_FOUND, "상위 게시판에는 글을 작성할 수 없습니다."); + INVALID_BOARD_TYPE(HttpStatus.BAD_REQUEST, "상위 게시판에는 글을 작성할 수 없습니다."); private final HttpStatus status; private final String message; From 5f03a8e0209024694145f2aa7b4fcae10858b3f1 Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Mon, 10 Nov 2025 19:36:11 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[BE][FIX]=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...stController.java => BoardController.java} | 98 ++- .../controller/PostInteractionController.java | 78 --- .../backend/board/entity/BoardType.java | 7 - .../backend/board/entity/PostType.java | 6 - .../service/PostInteractionServiceTest.java | 341 ++++++++++ .../board/service/PostServiceImplTest.java | 608 +++++++----------- 6 files changed, 668 insertions(+), 470 deletions(-) rename backend/src/main/java/org/sejongisc/backend/board/controller/{PostController.java => BoardController.java} (62%) delete mode 100644 backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java delete mode 100644 backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java create mode 100644 backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java similarity index 62% rename from backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java rename to backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java index fdc48274..61e4a03a 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -6,8 +6,10 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.CommentRequest; import org.sejongisc.backend.board.dto.PostRequest; import org.sejongisc.backend.board.dto.PostResponse; +import org.sejongisc.backend.board.service.PostInteractionService; import org.sejongisc.backend.board.service.PostService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.springframework.data.domain.Page; @@ -27,21 +29,22 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/post") +@RequestMapping("/api/board") @Tag( name = "게시판 및 게시물 API", description = "게시판 및 게시물 작성, 수정, 삭제 관련 API 제공" ) -public class PostController { +public class BoardController { private final PostService postService; + private final PostInteractionService postInteractionService; // 게시글 작성 @Operation( summary = "게시물 작성", description = "게시판 ID, 제목, 내용, 첨부파일을 포함한 게시물을 작성합니다." ) - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createPost( @Valid @ModelAttribute PostRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -57,7 +60,7 @@ public ResponseEntity createPost( + "첨부파일은 전체 파일 삭제 후 재저장 방식으로 이루어집니다." + "게시판 종류는 수정할 수 없습니다." ) - @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PutMapping(value = "/post/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updatePost( @Valid @ModelAttribute PostRequest request, @PathVariable UUID postId, @@ -74,7 +77,7 @@ public ResponseEntity updatePost( + "작성자 본인만 삭제할 수 있습니다." + "관련 첨부파일 및 댓글 등도 함께 삭제됩니다." ) - @DeleteMapping("/{postId}") + @DeleteMapping("/post/{postId}") public void deletePost( @PathVariable UUID postId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -89,7 +92,7 @@ public void deletePost( + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + "기본값은 페이지 번호 0, 페이지 크기 20입니다." ) - @GetMapping + @GetMapping("/posts") public ResponseEntity> getPosts( @RequestParam UUID boardId, @RequestParam(defaultValue = "0") int pageNumber, @@ -104,7 +107,7 @@ public ResponseEntity> getPosts( + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + "기본값은 페이지 번호 0, 페이지 크기 20입니다." ) - @GetMapping("/search") + @GetMapping("/posts/search") public ResponseEntity> searchPosts( @RequestParam UUID boardId, @RequestParam String keyword, @@ -120,7 +123,7 @@ public ResponseEntity> searchPosts( + "댓글에 대해서도 페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + "기본값은 댓글 페이지 번호 0, 댓글 페이지 크기 20입니다." ) - @GetMapping("/{postId}") + @GetMapping("/post/{postId}") public ResponseEntity getPostDetail( @PathVariable UUID postId, @RequestParam(defaultValue = "0") int commentPageNumber, @@ -135,7 +138,7 @@ public ResponseEntity getPostDetail( description = "게시판 이름과 상위 게시판 ID를 포함한 새로운 게시판을 생성합니다." + "상위 게시판의 ID가 null 이면 최상위 게시판으로 생성됩니다." ) - @PostMapping("/board") + @PostMapping public ResponseEntity createBoard( @RequestBody @Valid BoardRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -143,4 +146,81 @@ public ResponseEntity createBoard( postService.createBoard(request, userId); return ResponseEntity.ok().build(); } + + // 좋아요 토글 + @Operation( + summary = "좋아요 등록 및 취소", + description = "좋아요를 등록하거나 취소합니다. " + + "이미 좋아요를 한 게시물인 경우 좋아요가 취소되고, " + + "좋아요를 하지 않은 게시물인 경우 좋아요가 등록됩니다." + ) + @PostMapping("/{postId}/like") + public ResponseEntity toggleLike( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.toggleLike(postId, userId); + return ResponseEntity.ok().build(); + } + + // 북마크 토글 + @Operation( + summary = "북마크 등록 및 취소", + description = "북마크를 등록하거나 취소합니다. " + + "이미 북마크를 한 게시물인 경우 북마크가 취소되고, " + + "북마크를 하지 않은 게시물인 경우 북마크가 등록됩니다." + ) + @PostMapping("/{postId}/bookmark") + public ResponseEntity toggleBookmark( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.toggleBookmark(postId, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 작성 + @Operation( + summary = "댓글 작성", + description = "게시물에 댓글을 작성합니다." + + "parentCommentId가 제공되면 해당 댓글에 대한 대댓글로 작성됩니다." + + "null일 경우 일반 댓글로 작성됩니다." + ) + @PostMapping("/{postId}/comment") + public ResponseEntity createComment( + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.createComment(request, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 수정 + @Operation( + summary = "댓글 수정", + description = "댓글을 수정합니다. 작성자 본인만 수정할 수 있습니다." + ) + @PutMapping("/comment/{commentId}") + public void updateComment( + @PathVariable UUID commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.updateComment(request, commentId, userId); + } + + // 댓글 삭제 + @Operation( + summary = "댓글 삭제", + description = "댓글을 삭제합니다. " + + "작성자 본인과 관리자만 삭제할 수 있습니다." + + "대댓글이 있는 경우 함께 삭제됩니다." + ) + @DeleteMapping("/comment/{commentId}") + public void deleteComment( + @PathVariable UUID commentId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postInteractionService.deleteComment(commentId, userId); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java deleted file mode 100644 index 66797fca..00000000 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/PostInteractionController.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.sejongisc.backend.board.controller; - -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.board.dto.CommentRequest; -import org.sejongisc.backend.board.service.PostInteractionService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/post") -@Tag( - name = "게시물 기능 관련 API", - description = "댓글 작성, 수정, 삭제 및 좋아요, 북마크 API 제공" -) -public class PostInteractionController { - - private final PostInteractionService postInteractionService; - - // 좋아요 토글 - @PostMapping("/{postId}/like") - public ResponseEntity toggleLike( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postInteractionService.toggleLike(postId, userId); - return ResponseEntity.ok().build(); - } - - // 북마크 토글 - @PostMapping("/{postId}/bookmark") - public ResponseEntity toggleBookmark( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postInteractionService.toggleBookmark(postId, userId); - return ResponseEntity.ok().build(); - } - - // 댓글 작성 - @PostMapping("/{postId}/comment") - public ResponseEntity createComment( - @RequestBody CommentRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postInteractionService.createComment(request, userId); - return ResponseEntity.ok().build(); - } - - // 댓글 수정 - @PutMapping("/comment/{commentId}") - public void updateComment( - @PathVariable UUID commentId, - @RequestBody CommentRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postInteractionService.updateComment(request, commentId, userId); - } - - // 댓글 삭제 - @DeleteMapping("/comment/{commentId}") - public void deleteComment( - @PathVariable UUID commentId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postInteractionService.deleteComment(commentId, userId); - } -} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java b/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java deleted file mode 100644 index 722f6d8a..00000000 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.sejongisc.backend.board.entity; - -public enum BoardType { - GENERAL, // 전체 게시판 - FINANCE_IT, // 금융 IT 게시판 - ASSET_MANAGEMENT // 자산 운용 게시판 -} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java deleted file mode 100644 index fc8f4640..00000000 --- a/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.sejongisc.backend.board.entity; - -public enum PostType { - NORMAL, // 일반 - NOTICE // 공지 -} diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java new file mode 100644 index 00000000..b9ae930e --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java @@ -0,0 +1,341 @@ +package org.sejongisc.backend.board.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.entity.Comment; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostBookmark; +import org.sejongisc.backend.board.entity.PostLike; +import org.sejongisc.backend.board.repository.CommentRepository; +import org.sejongisc.backend.board.repository.PostBookmarkRepository; +import org.sejongisc.backend.board.repository.PostLikeRepository; +import org.sejongisc.backend.board.repository.PostRepository; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; + +@ExtendWith(MockitoExtension.class) +class PostInteractionServiceTest { + + @InjectMocks + private PostInteractionService postInteractionService; + + @Mock + private UserRepository userRepository; + @Mock + private PostRepository postRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private PostLikeRepository postLikeRepository; + @Mock + private PostBookmarkRepository postBookmarkRepository; + + // 테스트용 공유 객체 + private User mockUser; + private User mockAdmin; + private User mockOtherUser; + private Post mockPost; + private Comment mockParentComment; + private Comment mockChildComment; + + private UUID userId; + private UUID adminId; + private UUID otherUserId; + private UUID postId; + private UUID parentCommentId; + private UUID childCommentId; + + @BeforeEach + void setUp() { + // 사용자 UUID + userId = UUID.randomUUID(); + adminId = UUID.randomUUID(); + otherUserId = UUID.randomUUID(); + + // 엔티티 UUID + postId = UUID.randomUUID(); + parentCommentId = UUID.randomUUID(); + childCommentId = UUID.randomUUID(); + + // Mock 사용자 객체 + mockUser = User.builder().userId(userId).role(Role.TEAM_MEMBER).build(); + mockAdmin = User.builder().userId(adminId).role(Role.PRESIDENT).build(); + mockOtherUser = User.builder().userId(otherUserId).role(Role.TEAM_MEMBER).build(); + + // Mock 엔티티 객체 (모든 카운트를 Integer 0으로 초기화) + mockPost = Post.builder().postId(postId).likeCount(0).commentCount(0).bookmarkCount(0).build(); + + mockParentComment = Comment.builder() + .commentId(parentCommentId) + .user(mockUser) + .post(mockPost) + .parentComment(null) // 부모 댓글임 + .build(); + + mockChildComment = Comment.builder() + .commentId(childCommentId) + .user(mockOtherUser) + .post(mockPost) + .parentComment(mockParentComment) // 자식 댓글임 + .build(); + } + + @Test + @DisplayName("댓글 작성 - 성공 (원댓글)") + void createComment_Success_Parent() { + // given + CommentRequest request = new CommentRequest(postId, "새 댓글", null); + mockPost.setCommentCount(5); // 초기 댓글 수 (Integer) + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + + // when + postInteractionService.createComment(request, userId); + + // then + ArgumentCaptor commentCaptor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository, times(1)).save(commentCaptor.capture()); + + Comment savedComment = commentCaptor.getValue(); + assertThat(savedComment.getContent()).isEqualTo("새 댓글"); + assertThat(savedComment.getUser()).isEqualTo(mockUser); + assertThat(savedComment.getParentComment()).isNull(); + + // Count 검증 (Integer) + assertThat(mockPost.getCommentCount()).isEqualTo(6); + } + + @Test + @DisplayName("댓글 작성 - 성공 (대댓글)") + void createComment_Success_Child() { + // given + CommentRequest request = new CommentRequest(postId, "대댓글", parentCommentId); + mockPost.setCommentCount(5); // (Integer) + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(mockParentComment)); + + // when + postInteractionService.createComment(request, userId); + + // then + ArgumentCaptor commentCaptor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository, times(1)).save(commentCaptor.capture()); + + Comment savedComment = commentCaptor.getValue(); + assertThat(savedComment.getContent()).isEqualTo("대댓글"); + assertThat(savedComment.getParentComment()).isEqualTo(mockParentComment); + assertThat(mockPost.getCommentCount()).isEqualTo(6); + } + + @Test + @DisplayName("댓글 작성 - 실패 (대대댓글 시도)") + void createComment_Fail_ReplyToReply() { + // given + // mockChildComment는 parentComment를 부모로 가짐 (즉, 1-depth 대댓글임) + CommentRequest request = new CommentRequest(postId, "대대댓글", childCommentId); + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(commentRepository.findById(childCommentId)).thenReturn(Optional.of(mockChildComment)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + postInteractionService.createComment(request, userId); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.ALREADY_CHILD_COMMENT); + verify(commentRepository, never()).save(any(Comment.class)); + } + + @Test + @DisplayName("댓글 수정 - 성공") + void updateComment_Success() { + // given + CommentRequest request = new CommentRequest(null, "수정된 내용", null); + + // Mocking + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(mockParentComment)); + + // when + postInteractionService.updateComment(request, parentCommentId, userId); + + // then + assertThat(mockParentComment.getContent()).isEqualTo("수정된 내용"); + } + + @Test + @DisplayName("댓글 수정 - 실패 (작성자 불일치)") + void updateComment_Fail_InvalidOwner() { + // given + CommentRequest request = new CommentRequest(null, "수정 시도", null); + + // Mocking + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(mockParentComment)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + // mockParentComment의 작성자는 'userId'인데, 'otherUserId'로 수정 시도 + postInteractionService.updateComment(request, parentCommentId, otherUserId); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_COMMENT_OWNER); + } + + @Test + @DisplayName("댓글 삭제 - 성공 (작성자 본인, 자식 댓글 포함)") + void deleteComment_Success_AsOwner_WithChildren() { + // given + mockPost.setCommentCount(3); // 부모 1 + 자식 1 + 기타 1 + + // Mocking + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(mockParentComment)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + // 자식 댓글 1개 반환 + when(commentRepository.findByParentComment(mockParentComment)).thenReturn(List.of(mockChildComment)); + + // when + postInteractionService.deleteComment(parentCommentId, userId); + + // then + verify(commentRepository).deleteAll(List.of(mockChildComment)); // 자식 일괄 삭제 + verify(commentRepository).delete(mockParentComment); // 부모 삭제 + + // Count 검증 (3 - (1 + 1) = 1) (Integer) + assertThat(mockPost.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("댓글 삭제 - 성공 (관리자, 자식 댓글 없음)") + void deleteComment_Success_AsAdmin() { + // given + mockPost.setCommentCount(1); // 부모 댓글 1개 (Integer) + + // Mocking + when(commentRepository.findById(parentCommentId)).thenReturn(Optional.of(mockParentComment)); + when(userRepository.findById(adminId)).thenReturn(Optional.of(mockAdmin)); // 관리자(PRESIDENT) + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(commentRepository.findByParentComment(mockParentComment)).thenReturn(Collections.emptyList()); // 자식 없음 + + // when + // mockParentComment의 작성자는 'userId'이지만 'adminId'로 삭제 시도 + postInteractionService.deleteComment(parentCommentId, adminId); + + // then + verify(commentRepository, never()).deleteAll(any()); // 자식 삭제 호출 안 됨 + verify(commentRepository).delete(mockParentComment); // 부모 삭제 + + // Count 검증 (1 - 1 = 0) (Integer) + assertThat(mockPost.getCommentCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 토글 - 성공 (좋아요 추가)") + void toggleLike_Success_AddLike() { + // given + mockPost.setLikeCount(5); // (Integer) + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)).thenReturn(Optional.empty()); // 좋아요 없음 + + // when + postInteractionService.toggleLike(postId, userId); + + // then + verify(postLikeRepository).save(any(PostLike.class)); + verify(postLikeRepository, never()).delete(any()); + assertThat(mockPost.getLikeCount()).isEqualTo(6); // (Integer) + } + + @Test + @DisplayName("좋아요 토글 - 성공 (좋아요 취소)") + void toggleLike_Success_RemoveLike() { + // given + mockPost.setLikeCount(5); // (Integer) + PostLike existingLike = PostLike.builder().post(mockPost).user(mockUser).build(); + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)).thenReturn(Optional.of(existingLike)); // 좋아요 있음 + + // when + postInteractionService.toggleLike(postId, userId); + + // then + verify(postLikeRepository, never()).save(any(PostLike.class)); + verify(postLikeRepository).delete(existingLike); + assertThat(mockPost.getLikeCount()).isEqualTo(4); // (Integer) + } + + @Test + @DisplayName("북마크 토글 - 성공 (북마크 추가)") + void toggleBookmark_Success_AddBookmark() { + // given + mockPost.setBookmarkCount(5); // (Integer) + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)).thenReturn(Optional.empty()); // 북마크 없음 + + // when + postInteractionService.toggleBookmark(postId, userId); + + // then + verify(postBookmarkRepository).save(any(PostBookmark.class)); + verify(postBookmarkRepository, never()).delete(any()); + assertThat(mockPost.getBookmarkCount()).isEqualTo(6); // (Integer) + } + + @Test + @DisplayName("북마크 토글 - 성공 (북마크 취소)") + void toggleBookmark_Success_RemoveBookmark() { + // given + mockPost.setBookmarkCount(5); // (Integer) + PostBookmark existingBookmark = PostBookmark.builder().post(mockPost).user(mockUser).build(); + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)).thenReturn(Optional.of(existingBookmark)); // 북마크 있음 + + // when + postInteractionService.toggleBookmark(postId, userId); + + // then + verify(postBookmarkRepository, never()).save(any(PostBookmark.class)); + verify(postBookmarkRepository).delete(existingBookmark); + assertThat(mockPost.getBookmarkCount()).isEqualTo(4); // (Integer) + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java index 990d8eac..d94c7f77 100644 --- a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java @@ -1,443 +1,311 @@ package org.sejongisc.backend.board.service; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.*; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.stubbing.Answer; -import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.CommentResponse; import org.sejongisc.backend.board.dto.PostRequest; import org.sejongisc.backend.board.dto.PostResponse; -import org.sejongisc.backend.board.entity.*; -import org.sejongisc.backend.board.repository.*; +import org.sejongisc.backend.board.entity.Board; +import org.sejongisc.backend.board.entity.Comment; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostAttachment; +import org.sejongisc.backend.board.repository.BoardRepository; +import org.sejongisc.backend.board.repository.CommentRepository; +import org.sejongisc.backend.board.repository.PostAttachmentRepository; +import org.sejongisc.backend.board.repository.PostBookmarkRepository; +import org.sejongisc.backend.board.repository.PostLikeRepository; +import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; -import org.springframework.data.domain.*; -import org.springframework.mock.web.MockMultipartFile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; import org.springframework.web.multipart.MultipartFile; -import java.nio.charset.StandardCharsets; -import java.util.*; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class PostServiceImplTest { - @Mock UserRepository userRepository; - @Mock PostRepository postRepository; - @Mock CommentRepository commentRepository; - @Mock PostLikeRepository postLikeRepository; - @Mock PostBookmarkRepository postBookmarkRepository; - @Mock PostAttachmentRepository postAttachmentRepository; - @Mock FileUploadService fileUploadService; - - @InjectMocks - PostServiceImpl postService; - @InjectMocks - PostInteractionService postInteractionService; - - UUID userId; - User user; + private PostServiceImpl postService; + + @Mock + private UserRepository userRepository; + @Mock + private PostRepository postRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private PostLikeRepository postLikeRepository; + @Mock + private PostBookmarkRepository postBookmarkRepository; + @Mock + private PostAttachmentRepository postAttachmentRepository; + @Mock + private BoardRepository boardRepository; + @Mock + private FileUploadService fileUploadService; + + // 테스트용 공유 객체 + private User mockUser; + private Board mockBoard; + private Board mockParentBoard; + private Post mockPost; + private UUID userId; + private UUID boardId; + private UUID postId; @BeforeEach void setUp() { + // Mock 객체 기본 설정 userId = UUID.randomUUID(); - user = User.builder().userId(userId).role(Role.TEAM_MEMBER).build(); - } - - private PostRequest samplePostRequestWithFiles() { - MockMultipartFile f = new MockMultipartFile("files", "note.txt", "text/plain", - "hello".getBytes(StandardCharsets.UTF_8)); - PostRequest req = new PostRequest(); - req.setBoardType(BoardType.GENERAL); - req.setPostType(PostType.NORMAL); - req.setTitle("제목"); - req.setContent("내용"); - req.setFiles(List.of(f)); - return req; + boardId = UUID.randomUUID(); + postId = UUID.randomUUID(); + + mockUser = User.builder().userId(userId).build(); + mockParentBoard = Board.builder().boardId(UUID.randomUUID()).parentBoard(null).build(); + mockBoard = Board.builder().boardId(boardId).parentBoard(mockParentBoard).build(); + mockPost = Post.builder() + .postId(postId) + .user(mockUser) + .board(mockBoard) + .title("Test Title") + .content("Test Content") + .build(); } @Test - @DisplayName("게시글 저장 - 첨부파일 저장까지") - void savePost_withFiles() { - PostRequest req = samplePostRequestWithFiles(); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - UUID postId = UUID.randomUUID(); - Answer saveAnswer = inv -> { - Post p = inv.getArgument(0); - return Post.builder() - .postId(postId) - .user(p.getUser()) - .boardType(p.getBoardType()) - .title(p.getTitle()) - .content(p.getContent()) - .postType(p.getPostType()) - .build(); - }; - when(postRepository.save(any(Post.class))).thenAnswer(saveAnswer); - - when(fileUploadService.store(any(MultipartFile.class))).thenReturn("stored-note.txt"); - when(fileUploadService.getRootLocation()).thenReturn(java.nio.file.Path.of("/data/upload")); - - postService.savePost(req, userId); + @DisplayName("게시물 생성 - 성공 (첨부파일 포함)") + void savePost_Success() { + // given + MultipartFile mockFile = mock(MultipartFile.class); + when(mockFile.getOriginalFilename()).thenReturn("test.txt"); + when(mockFile.isEmpty()).thenReturn(false); + + PostRequest request = PostRequest.builder() + .boardId(boardId) + .title("New Post") + .content("New Content") + .files(List.of(mockFile)) + .build(); - verify(postRepository, times(1)).save(any(Post.class)); - verify(postAttachmentRepository, times(1)).save(argThat(att -> - "stored-note.txt".equals(att.getSavedFilename()) - && "note.txt".equals(att.getOriginalFilename()) - )); - } + // Mocking + when(boardRepository.findById(boardId)).thenReturn(Optional.of(mockBoard)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + when(fileUploadService.store(any(MultipartFile.class))).thenReturn("saved_filename.txt"); + when(fileUploadService.getRootLocation()).thenReturn(Paths.get("test/path")); + when(postRepository.save(any(Post.class))).thenReturn(mockPost); // 저장된 post 반환 - @Test - @DisplayName("게시글 수정 - 기존 첨부 삭제 후 신규 저장") - void updatePost_replaceFiles() { - UUID postId = UUID.randomUUID(); - PostRequest req = samplePostRequestWithFiles(); - - Post existing = Post.builder() - .postId(postId).user(user) - .title("old").content("old").postType(PostType.NORMAL) - .boardType(BoardType.GENERAL).build(); - - when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); - when(fileUploadService.store(any(MultipartFile.class))).thenReturn("new.txt"); - when(fileUploadService.getRootLocation()).thenReturn(java.nio.file.Path.of("/data/upload")); - when(postAttachmentRepository.findAllByPostPostId(postId)) - .thenReturn(List.of(PostAttachment.builder() - .post(existing).savedFilename("old.txt").build())); - - postService.updatePost(req, postId, userId); - - verify(fileUploadService).delete("old.txt"); - verify(postAttachmentRepository).deleteAllByPostPostId(postId); - verify(postAttachmentRepository).save(argThat(a -> - a.getPost().getPostId().equals(postId) && a.getSavedFilename().equals("new.txt"))); - assertThat(existing.getTitle()).isEqualTo("제목"); - assertThat(existing.getContent()).isEqualTo("내용"); - } + // when + postService.savePost(request, userId); - @Test - @DisplayName("게시글 수정 - 소유자 아님 -> 예외") - void updatePost_notOwner_throws() { - UUID postId = UUID.randomUUID(); - User other = User.builder().userId(UUID.randomUUID()).build(); - Post existing = Post.builder().postId(postId).user(other).build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); - - assertThatThrownBy(() -> postService.updatePost(new PostRequest(), postId, userId)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.INVALID_POST_OWNER.getMessage()); + // then + verify(postRepository, times(1)).save(any(Post.class)); + verify(fileUploadService, times(1)).store(any(MultipartFile.class)); + verify(postAttachmentRepository, times(1)).save(any(PostAttachment.class)); } @Test - @DisplayName("게시글 삭제 - 첨부/댓글/좋아요/북마크 삭제 포함") - void deletePost_allRelated() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).build(); - - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(postAttachmentRepository.findAllByPostPostId(postId)) - .thenReturn(List.of( - PostAttachment.builder().post(post).savedFilename("a.txt").build(), - PostAttachment.builder().post(post).savedFilename("b.txt").build() - )); + @DisplayName("게시물 생성 - 실패 (최상위 게시판에 작성 시도)") + void savePost_Fail_ParentBoard() { + // given + PostRequest request = PostRequest.builder().boardId(mockParentBoard.getBoardId()).build(); - postService.deletePost(postId, userId); + // Mocking + when(boardRepository.findById(mockParentBoard.getBoardId())).thenReturn(Optional.of(mockParentBoard)); - verify(fileUploadService).delete("a.txt"); - verify(fileUploadService).delete("b.txt"); - verify(postAttachmentRepository).deleteAllByPostPostId(postId); - verify(commentRepository).deleteAllByPostPostId(postId); - verify(postLikeRepository).deleteAllByPostPostId(postId); - verify(postBookmarkRepository).deleteAllByPostPostId(postId); - verify(postRepository).delete(post); - } + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + postService.savePost(request, userId); + }); - @Test - @DisplayName("게시글 삭제 - 소유자 아님 -> 예외") - void deletePost_notOwner_throws() { - UUID postId = UUID.randomUUID(); - User other = User.builder().userId(UUID.randomUUID()).build(); - Post existing = Post.builder().postId(postId).user(other).build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); - - assertThatThrownBy(() -> postService.deletePost(postId, userId)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.INVALID_POST_OWNER.getMessage()); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_BOARD_TYPE); } @Test - @DisplayName("게시글 목록 조회 - 매핑 검사") - void getPosts_mapping() { - Post p = Post.builder() - .postId(UUID.randomUUID()) - .user(user) - .boardType(BoardType.GENERAL) - .title("t") - .content("c") - .postType(PostType.NORMAL) - .bookmarkCount(1) - .likeCount(2) - .commentCount(3) + @DisplayName("게시물 수정 - 성공") + void updatePost_Success() { + // given + PostRequest request = PostRequest.builder() + .title("Updated Title") + .content("Updated Content") + .files(Collections.emptyList()) // 테스트 편의상 새 파일은 없음 .build(); - ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); - when(postRepository.findAllByBoardType(eq(BoardType.GENERAL), pageableCaptor.capture())) - .thenReturn(new PageImpl<>(List.of(p))); - - Page page = postService.getPosts(BoardType.GENERAL, 0, 20); - - assertThat(page.getContent()).hasSize(1); - PostResponse pr = page.getContent().get(0); - assertThat(pr.getTitle()).isEqualTo("t"); - assertThat(pr.getLikeCount()).isEqualTo(2); - - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Direction.DESC, "createdDate")); - } - - @Test - @DisplayName("게시글 검색 - 매핑 검사") - void searchPosts_mapping() { - Post p = Post.builder() - .postId(UUID.randomUUID()) - .user(user) - .boardType(BoardType.GENERAL) - .title("find me") - .content("c") - .postType(PostType.NORMAL) - .build(); - String keyword = "find"; + PostAttachment oldAttachment = PostAttachment.builder().savedFilename("old_file.txt").build(); - ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); - when(postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( - eq(keyword), eq(keyword), pageableCaptor.capture())) - .thenReturn(new PageImpl<>(List.of(p))); + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(postAttachmentRepository.findAllByPostPostId(postId)).thenReturn(List.of(oldAttachment)); - Page page = postService.searchPosts(keyword, 0, 20); + // when + postService.updatePost(request, postId, userId); - assertThat(page.getContent()).hasSize(1); - assertThat(page.getContent().get(0).getTitle()).isEqualTo("find me"); - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Direction.DESC, "createdDate")); + // then + verify(postAttachmentRepository).deleteAllByPostPostId(postId); + verify(fileUploadService).delete("old_file.txt"); + assertThat(mockPost.getTitle()).isEqualTo("Updated Title"); + assertThat(mockPost.getContent()).isEqualTo("Updated Content"); } @Test - @DisplayName("게시글 상세 조회 - 댓글(페이징)과 첨부파일 포함") - void getPostDetail_withCommentsAndAttachments() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder() - .postId(postId).user(user).title("detail").content("detail content") - .build(); - - Comment comment = Comment.builder().commentId(UUID.randomUUID()).post(post).user(user) - .content("comment 1").build(); - Page commentPage = new PageImpl<>(List.of(comment)); - - PostAttachment attachment = PostAttachment.builder() - .postAttachmentId(UUID.randomUUID()).post(post).savedFilename("file.txt").originalFilename("orig.txt") - .build(); - List attachmentList = List.of(attachment); - - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); - when(commentRepository.findAllByPostPostId(eq(postId), pageableCaptor.capture())) - .thenReturn(commentPage); - when(postAttachmentRepository.findAllByPostPostId(postId)) - .thenReturn(attachmentList); - - PostResponse response = postService.getPostDetail(postId, 0, 10); - - assertThat(response.getPostId()).isEqualTo(postId); - assertThat(response.getTitle()).isEqualTo("detail"); + @DisplayName("게시물 수정 - 실패 (작성자 불일치)") + void updatePost_Fail_InvalidOwner() { + // given + UUID otherUserId = UUID.randomUUID(); + PostRequest request = PostRequest.builder().build(); - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Direction.ASC, "createdDate")); + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); // mockPost의 user는 'userId' - assertThat(response.getComments()).isNotNull(); - assertThat(response.getComments().getTotalElements()).isEqualTo(1); - assertThat(response.getComments().getContent().get(0).getContent()).isEqualTo("comment 1"); + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + postService.updatePost(request, postId, otherUserId); // 다른 'otherUserId'로 수정 시도 + }); - assertThat(response.getAttachments()).isNotNull(); - assertThat(response.getAttachments()).hasSize(1); - assertThat(response.getAttachments().get(0).getOriginalFilename()).isEqualTo("orig.txt"); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_POST_OWNER); } @Test - @DisplayName("댓글 생성 - 댓글수 증가") - void createComment_increaseCount() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).commentCount(0).build(); + @DisplayName("게시물 삭제 - 성공") + void deletePost_Success() { + // given + PostAttachment attachment = PostAttachment.builder().savedFilename("file_to_delete.txt").build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(postAttachmentRepository.findAllByPostPostId(postId)).thenReturn(List.of(attachment)); - CommentRequest req = new CommentRequest(); - req.setPostId(postId); - req.setContent("hi"); - - postInteractionService.createComment(req, userId); + // when + postService.deletePost(postId, userId); - verify(commentRepository).save(any(Comment.class)); - assertThat(post.getCommentCount()).isEqualTo(1); + // then + verify(postAttachmentRepository).deleteAllByPostPostId(postId); + verify(fileUploadService).delete("file_to_delete.txt"); + verify(commentRepository).deleteAllByPostPostId(postId); + verify(postLikeRepository).deleteAllByPostPostId(postId); + verify(postBookmarkRepository).deleteAllByPostPostId(postId); + verify(postRepository).delete(mockPost); } @Test - @DisplayName("댓글 수정 - 성공") - void updateComment_success() { - UUID commentId = UUID.randomUUID(); - Comment comment = Comment.builder().commentId(commentId).user(user).content("old content") - .build(); - CommentRequest req = new CommentRequest(); - req.setContent("new content"); - - when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - - postInteractionService.updateComment(req, commentId, userId); - - assertThat(comment.getContent()).isEqualTo("new content"); + @DisplayName("게시물 목록 조회 - 성공") + void getPosts_Success() { + // given + int page = 0; + int size = 10; + Pageable pageable = PageRequest.of(page, size, Sort.by(Direction.DESC, "createdDate")); + List postList = List.of(mockPost); + Page postPage = new PageImpl<>(postList, pageable, postList.size()); + + // Mocking + when(boardRepository.findById(boardId)).thenReturn(Optional.of(mockBoard)); + when(postRepository.findAllByBoard(mockBoard, pageable)).thenReturn(postPage); + + // when + Page result = postService.getPosts(boardId, page, size); + + // then + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getPostId()).isEqualTo(postId); } @Test - @DisplayName("댓글 수정 - 소유자 아님 -> 예외") - void updateComment_notOwner_throws() { - UUID commentId = UUID.randomUUID(); - User other = User.builder().userId(UUID.randomUUID()).build(); - Comment comment = Comment.builder().commentId(commentId).user(other).content("old content") - .build(); - CommentRequest req = new CommentRequest(); - req.setContent("new content"); - - when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - - assertThatThrownBy(() -> postInteractionService.updateComment(req, commentId, userId)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); + @DisplayName("게시물 상세 조회 - 성공 (대댓글 포함)") + void getPostDetail_Success_WithReplies() { + // given + int page = 0; + int size = 10; + Pageable commentPageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "createdDate")); + + // 댓글 Mock 데이터 생성 + Comment parentComment = Comment.builder().commentId(UUID.randomUUID()).post(mockPost).content("부모댓글1").parentComment(null).build(); + Comment childComment = Comment.builder().commentId(UUID.randomUUID()).post(mockPost).content("대댓글1").parentComment(parentComment).build(); + + Page parentCommentPage = new PageImpl<>(List.of(parentComment), commentPageable, 1); + + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + // 1. 부모 댓글(parentComment == null) 조회 Mocking + when(commentRepository.findAllByPostPostIdAndParentCommentIsNull(postId, commentPageable)) + .thenReturn(parentCommentPage); + // 2. 자식 댓글 조회 Mocking + when(commentRepository.findByParentComment(parentComment)).thenReturn(List.of(childComment)); + // 3. 첨부파일 조회 Mocking + when(postAttachmentRepository.findAllByPostPostId(postId)).thenReturn(Collections.emptyList()); + + // when + PostResponse result = postService.getPostDetail(postId, page, size); + + // then + assertThat(result).isNotNull(); + assertThat(result.getPostId()).isEqualTo(postId); + + // 댓글 검증 + Page commentPage = result.getComments(); + assertThat(commentPage.getTotalElements()).isEqualTo(1); // 부모 댓글 1개 + + CommentResponse parentResponse = commentPage.getContent().get(0); + assertThat(parentResponse.getContent()).isEqualTo("부모댓글1"); + + // 대댓글 검증 + assertThat(parentResponse.getReplies()).isNotNull(); + assertThat(parentResponse.getReplies().size()).isEqualTo(1); + assertThat(parentResponse.getReplies().get(0).getContent()).isEqualTo("대댓글1"); + + // N+1 쿼리 호출 검증 (부모 댓글 수만큼 findByParentComment 호출) + verify(commentRepository, times(1)).findByParentComment(parentComment); } - @Test - @DisplayName("댓글 삭제 - 작성자 또는 관리자만 가능, 댓글수 감소") - void deleteComment_ownerOrAdmin() { - UUID commentId = UUID.randomUUID(); - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).commentCount(3).build(); - Comment comment = Comment.builder().commentId(commentId).post(post).user(user).content("c") + @DisplayName("게시판 생성 - 성공") + void createBoard_Success() { + // given + BoardRequest request = BoardRequest.builder() + .boardName("새 게시판") + .parentBoardId(mockParentBoard.getBoardId()) .build(); - when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - - postInteractionService.deleteComment(commentId, userId); - verify(commentRepository).delete(comment); - assertThat(post.getCommentCount()).isEqualTo(2); - - reset(commentRepository, userRepository, postRepository); - User admin = User.builder().userId(UUID.randomUUID()).role(Role.PRESIDENT).build(); - User otherUser = User.builder().userId(UUID.randomUUID()).role(Role.TEAM_MEMBER).build(); - Comment othersComment = Comment.builder().commentId(commentId).post(post) - .user(otherUser) - .content("c").build(); - - when(commentRepository.findById(commentId)).thenReturn(Optional.of(othersComment)); - when(userRepository.findById(admin.getUserId())).thenReturn(Optional.of(admin)); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - - post.setCommentCount(5); - postInteractionService.deleteComment(commentId, admin.getUserId()); - verify(commentRepository).delete(othersComment); - assertThat(post.getCommentCount()).isEqualTo(4); - } + // Mocking + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); - @Test - @DisplayName("댓글 삭제 - 소유자/관리자 아님 -> 예외") - void deleteComment_notOwnerOrAdmin_throws() { - UUID commentId = UUID.randomUUID(); - UUID postId = UUID.randomUUID(); - User other = User.builder().userId(UUID.randomUUID()).role(Role.TEAM_MEMBER).build(); - Post post = Post.builder().postId(postId).user(other).commentCount(1).build(); - Comment comment = Comment.builder().commentId(commentId).post(post).user(other).build(); - - when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> postInteractionService.deleteComment(commentId, userId)) - .isInstanceOf(CustomException.class) - .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); - - verify(postRepository, never()).findById(any()); - assertThat(post.getCommentCount()).isEqualTo(1); - } + // ArgumentCaptor: save(board)에 실제 어떤 board 객체가 전달되었는지 잡기 위함 + ArgumentCaptor boardCaptor = ArgumentCaptor.forClass(Board.class); - @Test - @DisplayName("좋아요 토글 - 새로 추가") - void toggleLike_add() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).likeCount(0).build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) - .thenReturn(Optional.empty()); - - postInteractionService.toggleLike(postId, userId); - - verify(postLikeRepository).save(argThat(l -> - l.getPost().getPostId().equals(postId) && l.getUser().getUserId().equals(userId))); - assertThat(post.getLikeCount()).isEqualTo(1); - } + // when + postService.createBoard(request, userId); - @Test - @DisplayName("좋아요 토글 - 취소") - void toggleLike_remove() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).likeCount(2).build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - PostLike like = PostLike.builder().post(post).user(user).build(); - when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) - .thenReturn(Optional.of(like)); - - postInteractionService.toggleLike(postId, userId); - - verify(postLikeRepository).delete(like); - assertThat(post.getLikeCount()).isEqualTo(1); - } + // then + verify(boardRepository).save(boardCaptor.capture()); + Board savedBoard = boardCaptor.getValue(); - @Test - @DisplayName("북마크 토글 - 추가/취소") - void toggleBookmark_add_and_remove() { - UUID postId = UUID.randomUUID(); - Post post = Post.builder().postId(postId).user(user).bookmarkCount(0).build(); - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) - .thenReturn(Optional.empty()); - postInteractionService.toggleBookmark(postId, userId); - verify(postBookmarkRepository).save(argThat(b -> - b.getPost().getPostId().equals(postId) && b.getUser().getUserId().equals(userId))); - assertThat(post.getBookmarkCount()).isEqualTo(1); - - reset(postBookmarkRepository); - PostBookmark existingBookmark = PostBookmark.builder().post(post).user(user).build(); - when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) - .thenReturn(Optional.of(existingBookmark)); - - postInteractionService.toggleBookmark(postId, userId); - - verify(postBookmarkRepository).delete(existingBookmark); - assertThat(post.getBookmarkCount()).isEqualTo(0); + assertThat(savedBoard.getBoardName()).isEqualTo("새 게시판"); + assertThat(savedBoard.getCreatedBy()).isEqualTo(mockUser); + assertThat(savedBoard.getParentBoard().getBoardId()).isEqualTo(mockParentBoard.getBoardId()); } } \ No newline at end of file From 5ed7c8e28a38c312a8cf0326b79009c5a3318504 Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Mon, 10 Nov 2025 20:07:46 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[BE][FIX]=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/board/controller/BoardController.java | 2 +- .../backend/board/repository/PostRepository.java | 11 +++++++++-- .../backend/board/service/PostServiceImpl.java | 9 ++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java index 61e4a03a..97fa3298 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -186,7 +186,7 @@ public ResponseEntity toggleBookmark( + "parentCommentId가 제공되면 해당 댓글에 대한 대댓글로 작성됩니다." + "null일 경우 일반 댓글로 작성됩니다." ) - @PostMapping("/{postId}/comment") + @PostMapping("/comment") public ResponseEntity createComment( @RequestBody CommentRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java index 51b7cb3b..ba03248d 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository { @@ -14,6 +16,11 @@ Page findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( Page findAllByBoard(Board board, Pageable pageable); - Page findAllByBoardAndTitleContainingIgnoreCaseOrContentContainingIgnoreCase( - Board board, String titleKeyword, String contentKeyword, Pageable pageable); + @Query("SELECT p FROM Post p WHERE p.board = :board AND (" + + "LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%')))") + Page searchByBoardAndKeyword( + @Param("board") Board board, + @Param("keyword") String keyword, + Pageable pageable); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index b3b3d136..ffb0d1ed 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -202,8 +202,8 @@ public Page searchPosts(UUID boardId, String keyword, int pageNumb .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); // 해당 키워드가 들어간 게시물 검색 - Page posts = postRepository.findAllByBoardAndTitleContainingIgnoreCaseOrContentContainingIgnoreCase( - board, keyword, keyword, pageable); + Page posts = postRepository.searchByBoardAndKeyword( + board, keyword, pageable); return posts.map(this::mapToPostResponse); } @@ -273,9 +273,8 @@ public void createBoard(BoardRequest request, UUID userId) { Board board; // 하위 게시판인 경우 if (request.getParentBoardId() != null) { - Board parentBoard = Board.builder() - .boardId(request.getParentBoardId()) - .build(); + Board parentBoard = boardRepository.findById(request.getParentBoardId()) + .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); board = Board.builder() .boardName(request.getBoardName()) From 2ed86c87f00e1e0ff0732d945df7cd8b9af7bff6 Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Wed, 19 Nov 2025 17:37:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[BE][FEAT]=EB=B6=80=EB=AA=A8=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/BoardController.java | 13 +++++++ .../backend/board/dto/BoardResponse.java | 37 +++++++++++++++++++ .../board/repository/BoardRepository.java | 2 + .../backend/board/service/PostService.java | 5 +++ .../board/service/PostServiceImpl.java | 13 +++++++ 5 files changed, 70 insertions(+) create mode 100644 backend/src/main/java/org/sejongisc/backend/board/dto/BoardResponse.java diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java index 97fa3298..3c833ed8 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -3,9 +3,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.BoardResponse; import org.sejongisc.backend.board.dto.CommentRequest; import org.sejongisc.backend.board.dto.PostRequest; import org.sejongisc.backend.board.dto.PostResponse; @@ -147,6 +149,17 @@ public ResponseEntity createBoard( return ResponseEntity.ok().build(); } + // 게시판 생성 + @Operation( + summary = "부모 게시판 목록 조회", + description = "최상위 부모 게시판들의 목록을 조회합니다." + ) + @PostMapping + public ResponseEntity> getParentBoards( + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok(postService.getParentBoards()); + } + // 좋아요 토글 @Operation( summary = "좋아요 등록 및 취소", diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/BoardResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/BoardResponse.java new file mode 100644 index 00000000..b7a32c94 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/BoardResponse.java @@ -0,0 +1,37 @@ +package org.sejongisc.backend.board.dto; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.sejongisc.backend.board.entity.Board; +import org.sejongisc.backend.user.entity.User; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class BoardResponse { + + private UUID boardId; + + private String boardName; + + private User createdBy; + + private UUID parentBoardId; + + public static BoardResponse of(Board board) { + return BoardResponse.builder() + .boardId(board.getBoardId()) + .boardName(board.getBoardName()) + .createdBy(board.getCreatedBy()) + .parentBoardId(board.getParentBoard() != null ? board.getParentBoard().getBoardId() : null) + .build(); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java index 111e1b4b..03639b72 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/BoardRepository.java @@ -1,9 +1,11 @@ package org.sejongisc.backend.board.repository; +import java.util.List; import java.util.UUID; import org.sejongisc.backend.board.entity.Board; import org.springframework.data.jpa.repository.JpaRepository; public interface BoardRepository extends JpaRepository { + List findAllByParentBoardIsNull(); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java index 326e443b..5af7d3fc 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java @@ -1,7 +1,9 @@ package org.sejongisc.backend.board.service; +import java.util.List; import java.util.UUID; import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.BoardResponse; import org.sejongisc.backend.board.dto.PostRequest; import org.sejongisc.backend.board.dto.PostResponse; import org.springframework.data.domain.Page; @@ -28,4 +30,7 @@ public interface PostService { // 게시판 생성 void createBoard(BoardRequest request, UUID userId); + + // 부모 게시판 목록 조회 + List getParentBoards(); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index ffb0d1ed..5d00a363 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.board.dto.BoardRequest; +import org.sejongisc.backend.board.dto.BoardResponse; import org.sejongisc.backend.board.dto.CommentResponse; import org.sejongisc.backend.board.dto.PostAttachmentResponse; import org.sejongisc.backend.board.dto.PostRequest; @@ -265,6 +266,7 @@ public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { } // 게시판 생성 + @Override @Transactional public void createBoard(BoardRequest request, UUID userId) { User user = userRepository.findById(userId) @@ -293,6 +295,17 @@ public void createBoard(BoardRequest request, UUID userId) { boardRepository.save(board); } + // 부모 게시판 조회 + @Override + @Transactional(readOnly = true) + public List getParentBoards() { + List parentBoards = boardRepository.findAllByParentBoardIsNull(); + + return parentBoards.stream() + .map(BoardResponse::of) + .toList(); + } + private PostResponse mapToPostResponse(Post post) { return PostResponse.builder() .postId(post.getPostId()) From a04429108ed16f69ffd900067aa4de5e241af3ba Mon Sep 17 00:00:00 2001 From: nayoungKim Date: Wed, 19 Nov 2025 18:05:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[BE][FEAT]=EB=B6=80=EB=AA=A8=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/board/controller/BoardController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java index 3c833ed8..3b270d96 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -154,7 +154,7 @@ public ResponseEntity createBoard( summary = "부모 게시판 목록 조회", description = "최상위 부모 게시판들의 목록을 조회합니다." ) - @PostMapping + @GetMapping("/parents") public ResponseEntity> getParentBoards( @AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(postService.getParentBoards());