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 new file mode 100644 index 00000000..97fa3298 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -0,0 +1,226 @@ +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.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; +import org.springframework.http.MediaType; +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.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/board") +@Tag( + name = "게시판 및 게시물 API", + description = "게시판 및 게시물 작성, 수정, 삭제 관련 API 제공" +) +public class BoardController { + + private final PostService postService; + private final PostInteractionService postInteractionService; + + // 게시글 작성 + @Operation( + summary = "게시물 작성", + description = "게시판 ID, 제목, 내용, 첨부파일을 포함한 게시물을 작성합니다." + ) + @PostMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createPost( + @Valid @ModelAttribute PostRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.savePost(request, userId); + return ResponseEntity.ok().build(); + } + + // 게시글 수정 + @Operation( + summary = "게시물 수정", + description = "제목, 내용, 첨부파일을 포함한 게시물을 수정합니다." + + "첨부파일은 전체 파일 삭제 후 재저장 방식으로 이루어집니다." + + "게시판 종류는 수정할 수 없습니다." + ) + @PutMapping(value = "/post/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updatePost( + @Valid @ModelAttribute PostRequest request, + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.updatePost(request, postId, userId); + return ResponseEntity.ok().build(); + } + + // 게시글 삭제 + @Operation( + summary = "게시글 삭제", + description = "게시글 ID를 통해 게시글을 삭제합니다." + + "작성자 본인만 삭제할 수 있습니다." + + "관련 첨부파일 및 댓글 등도 함께 삭제됩니다." + ) + @DeleteMapping("/post/{postId}") + public void deletePost( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.deletePost(postId, userId); + } + + // 게시글 조회 + @Operation( + summary = "게시글 조회", + description = "게시판 ID를 통해 해당 게시판의 게시글 목록을 조회합니다." + + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 페이지 번호 0, 페이지 크기 20입니다." + ) + @GetMapping("/posts") + public ResponseEntity> getPosts( + @RequestParam UUID boardId, + @RequestParam(defaultValue = "0") int pageNumber, + @RequestParam(defaultValue = "20") int pageSize) { + return ResponseEntity.ok(postService.getPosts(boardId, pageNumber, pageSize)); + } + + // 게시글 검색 + @Operation( + summary = "게시글 검색", + description = "게시판 ID와 키워드를 통해 해당 게시판의 게시글 목록을 검색합니다." + + "페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 페이지 번호 0, 페이지 크기 20입니다." + ) + @GetMapping("/posts/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(boardId, keyword, pageNumber, pageSize)); + } + + // 게시물 상세 조회 + @Operation( + summary = "게시물 상세 조회", + description = "게시물 ID를 통해 게시물의 상세 정보를 조회합니다." + + "댓글에 대해서도 페이지 번호와 페이지 크기를 통해 페이징 처리가 가능합니다." + + "기본값은 댓글 페이지 번호 0, 댓글 페이지 크기 20입니다." + ) + @GetMapping("/post/{postId}") + public ResponseEntity getPostDetail( + @PathVariable UUID postId, + @RequestParam(defaultValue = "0") int commentPageNumber, + @RequestParam(defaultValue = "20") int commentPageSize) { + PostResponse response = postService.getPostDetail(postId, commentPageNumber, commentPageSize); + return ResponseEntity.ok(response); + } + + // 게시판 생성 + @Operation( + summary = "게시판 생성", + description = "게시판 이름과 상위 게시판 ID를 포함한 새로운 게시판을 생성합니다." + + "상위 게시판의 ID가 null 이면 최상위 게시판으로 생성됩니다." + ) + @PostMapping + public ResponseEntity createBoard( + @RequestBody @Valid BoardRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + 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("/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/PostController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java deleted file mode 100644 index 8e09fcff..00000000 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java +++ /dev/null @@ -1,137 +0,0 @@ -package org.sejongisc.backend.board.controller; - -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -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.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; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/post") -@Tag( - name = "게시글 및 댓글 API", - description = "게시글 및 댓글 작성, 수정, 삭제 관련 API 제공" -) -public class PostController { - - private final PostService postService; - - // 게시글 작성 - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createPost( - @Valid @ModelAttribute PostRequest request, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.savePost(request, userId); - return ResponseEntity.ok().build(); - } - - // 게시글 수정 - @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity updatePost( - @Valid @ModelAttribute PostRequest request, - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.updatePost(request, postId, userId); - return ResponseEntity.ok().build(); - } - - // 게시글 삭제 - @DeleteMapping("/{postId}") - public void deletePost( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.deletePost(postId, userId); - } - - // 게시글 조회 (공지/일반) - @GetMapping - public ResponseEntity> getPosts( - @RequestParam BoardType boardType, - @RequestParam(defaultValue = "0") int pageNumber, - @RequestParam(defaultValue = "20") int pageSize) { - return ResponseEntity.ok(postService.getPosts(boardType, pageNumber, pageSize)); - } - - // 게시글 검색 - @GetMapping("/search") - public ResponseEntity> searchPosts( - @RequestParam String keyword, - @RequestParam(defaultValue = "0") int pageNumber, - @RequestParam(defaultValue = "20") int pageSize) { - return ResponseEntity.ok(postService.searchPosts(keyword, pageNumber, pageSize)); - } - - // 게시물 상세 조회 - @GetMapping("/{postId}") - public ResponseEntity getPostDetail( - @PathVariable UUID postId, - @RequestParam(defaultValue = "0") int commentPageNumber, - @RequestParam(defaultValue = "20") int commentPageSize) { - PostResponse response = postService.getPostDetail(postId, commentPageNumber, commentPageSize); - return ResponseEntity.ok(response); - } - - // 좋아요 토글 - @PostMapping("/{postId}/like") - public ResponseEntity toggleLike( - @PathVariable UUID postId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - postService.toggleLike(postId, userId); - 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/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/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/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..74cae301 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Board.java @@ -0,0 +1,43 @@ +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; +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; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +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 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/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/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 26254d61..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,11 +35,13 @@ public class Post extends BasePostgresEntity { // 작성자 @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private User user; // 게시판 타입 - @Enumerated(EnumType.STRING) - private BoardType boardType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; // 제목 @Column(nullable = false) @@ -39,10 +51,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/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/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/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/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/repository/PostRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java index 7ab8316a..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 @@ -1,17 +1,26 @@ 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; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; 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); + + @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/PostInteractionService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java new file mode 100644 index 00000000..4bd0862b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java @@ -0,0 +1,214 @@ +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; +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 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); + + // 게시글의 댓글 수 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); + } + + // 게시글 조회 + Post post = postRepository.findById(comment.getPost().getPostId()) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 자식 댓글 조회 + List childComments = commentRepository.findByParentComment(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); + } + + // 좋아요 등록/삭제 + @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..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 @@ -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, 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 e40c9e4a..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 @@ -1,32 +1,40 @@ 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 +43,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 +102,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 +167,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.searchByBoardAndKeyword( + board, keyword, pageable); return posts.map(this::mapToPostResponse); } @@ -199,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) @@ -214,11 +250,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 +264,42 @@ 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)); - - // 유저 조회 + public void createBoard(BoardRequest request, UUID userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - // 이미 좋아요를 눌렀는지 확인 - Optional existingLike = postLikeRepository.findByPostPostIdAndUserUserId(postId, userId); + Board board; + // 하위 게시판인 경우 + if (request.getParentBoardId() != null) { + Board parentBoard = boardRepository.findById(request.getParentBoardId()) + .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); - if (existingLike.isPresent()) { - // 좋아요가 이미 있으면 -> 삭제 (좋아요 취소) - postLikeRepository.delete(existingLike.get()); - post.setLikeCount(post.getLikeCount() - 1); // Post 엔티티 카운트 감소 - } else { - // 좋아요가 없으면 -> 생성 (좋아요) - PostLike newLike = PostLike.builder() - .post(post) - .user(user) + board = Board.builder() + .boardName(request.getBoardName()) + .createdBy(user) + .parentBoard(parentBoard) .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 엔티티 카운트 감소 } else { - // 북마크가 없으면 -> 생성 (북마크) - PostBookmark newBookmark = PostBookmark.builder() - .post(post) - .user(user) + // 상위 게시판인 경우 + board = Board.builder() + .boardName(request.getBoardName()) + .createdBy(user) + .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..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 @@ -82,7 +82,15 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."), - INVALID_COMMENT_OWNER(HttpStatus.FORBIDDEN, "댓글 수정/삭제 권한이 없습니다."); + 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.BAD_REQUEST, "상위 게시판에는 글을 작성할 수 없습니다."); private final HttpStatus status; private final String message; 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 5b1bb693..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,440 +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; - - 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))); + PostAttachment oldAttachment = PostAttachment.builder().savedFilename("old_file.txt").build(); - Page page = postService.getPosts(BoardType.GENERAL, 0, 20); + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(postAttachmentRepository.findAllByPostPostId(postId)).thenReturn(List.of(oldAttachment)); - assertThat(page.getContent()).hasSize(1); - PostResponse pr = page.getContent().get(0); - assertThat(pr.getTitle()).isEqualTo("t"); - assertThat(pr.getLikeCount()).isEqualTo(2); + // when + postService.updatePost(request, postId, userId); - 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 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"; + @DisplayName("게시물 수정 - 실패 (작성자 불일치)") + void updatePost_Fail_InvalidOwner() { + // given + UUID otherUserId = UUID.randomUUID(); + PostRequest request = PostRequest.builder().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)); // mockPost의 user는 'userId' - Page page = postService.searchPosts(keyword, 0, 20); + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + postService.updatePost(request, postId, otherUserId); // 다른 'otherUserId'로 수정 시도 + }); - 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")); - } - - @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"); - - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Direction.ASC, "createdDate")); - - assertThat(response.getComments()).isNotNull(); - assertThat(response.getComments().getTotalElements()).isEqualTo(1); - assertThat(response.getComments().getContent().get(0).getContent()).isEqualTo("comment 1"); - - 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(); - - when(postRepository.findById(postId)).thenReturn(Optional.of(post)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + @DisplayName("게시물 삭제 - 성공") + void deletePost_Success() { + // given + PostAttachment attachment = PostAttachment.builder().savedFilename("file_to_delete.txt").build(); - CommentRequest req = new CommentRequest(); - req.setPostId(postId); - req.setContent("hi"); + // Mocking + when(postRepository.findById(postId)).thenReturn(Optional.of(mockPost)); + when(postAttachmentRepository.findAllByPostPostId(postId)).thenReturn(List.of(attachment)); - postService.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)); - - postService.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(() -> postService.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)); - - postService.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); - postService.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(() -> postService.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()); - - postService.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)); - - postService.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()); - postService.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)); - - postService.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