diff --git a/src/main/java/com/project/InsightPrep/domain/post/controller/PostController.java b/src/main/java/com/project/InsightPrep/domain/post/controller/PostController.java new file mode 100644 index 0000000..b59b584 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/controller/PostController.java @@ -0,0 +1,102 @@ +package com.project.InsightPrep.domain.post.controller; + +import com.project.InsightPrep.domain.post.controller.docs.PostControllerDocs; +import com.project.InsightPrep.domain.post.dto.CommentRequest; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.post.dto.PostRequest; +import com.project.InsightPrep.domain.post.dto.PostResponse; +import com.project.InsightPrep.domain.post.dto.PostResponse.Created; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.post.service.CommentService; +import com.project.InsightPrep.domain.post.service.SharedPostService; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.global.common.response.ApiResponse; +import com.project.InsightPrep.global.common.response.code.ApiSuccessCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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 +@RequestMapping("/post") +@RequiredArgsConstructor +public class PostController implements PostControllerDocs { + + private final SharedPostService sharedPostService; + private final CommentService commentService; + + @PostMapping + @PreAuthorize("hasRole('USER')") + public ResponseEntity> create(@RequestBody @Valid PostRequest.Create req) { + Long postId = sharedPostService.createPost(req); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.CREATE_POST_SUCCESS, new PostResponse.Created(postId))); + } + + @GetMapping("/{postId}") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> getPost(@PathVariable long postId) { + PostDetailDto dto = sharedPostService.getPostDetail(postId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.GET_POST_SUCCESS, dto)); + } + + @PatchMapping("/{postId}/resolve") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> resolve(@PathVariable long postId) { + sharedPostService.resolve(postId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.UPDATE_POST_STATUS_SUCCESS)); + } + + @GetMapping + @PreAuthorize("hasRole('USER')") + public ResponseEntity>> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ) { + PageResponse body = sharedPostService.getPosts(page, size); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.GET_POSTS_SUCCESS, body)); + } + + @PostMapping("/{postId}/comments") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> createComment(@PathVariable long postId, @RequestBody @Valid CommentRequest.CreateDto req) { + CommentRes res = commentService.createComment(postId, req); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.CREATE_COMMENT_SUCCESS, res)); + } + + @PutMapping("/{postId}/comments/{commentId}") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> updateComment(@PathVariable long postId, @PathVariable long commentId, @RequestBody @Valid CommentRequest.UpdateDto req) { + commentService.updateComment(postId, commentId, req); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.UPDATE_COMMENT_SUCCESS)); + } + + @DeleteMapping("/{postId}/comments/{commentId}") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> deleteComment(@PathVariable long postId, @PathVariable long commentId) { + commentService.deleteComment(postId, commentId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.DELETE_COMMENT_SUCCESS)); + } + + @GetMapping("/{postId}/comments") + @PreAuthorize("hasRole('USER')") + public ResponseEntity>> listComments( + @PathVariable long postId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ) { + PageResponse res = commentService.getComments(postId, page, size); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.GET_COMMENTS_SUCCESS, res)); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/controller/docs/PostControllerDocs.java b/src/main/java/com/project/InsightPrep/domain/post/controller/docs/PostControllerDocs.java new file mode 100644 index 0000000..28553ae --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/controller/docs/PostControllerDocs.java @@ -0,0 +1,53 @@ +package com.project.InsightPrep.domain.post.controller.docs; + +import com.project.InsightPrep.domain.post.dto.CommentRequest; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.post.dto.PostRequest; +import com.project.InsightPrep.domain.post.dto.PostResponse.Created; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Discussions", description = "Discussions 관련 API") +public interface PostControllerDocs { + + @Operation(summary = "토론 글 등록", description = "특정 질문에 대하여 글을 등록합니다.") + public ResponseEntity> create(@RequestBody @Valid PostRequest.Create req); + + @Operation(summary = "토론 글 조회", description = "글을 조회합니다.") + public ResponseEntity> getPost(@PathVariable long postId); + + @Operation(summary = "본인 글 상태 변경", description = "본인이 작성한 글의 상태를 해결 완료 상태로 변경합니다.") + public ResponseEntity> resolve(@PathVariable long postId); + + @Operation(summary = "글 리스트 조회", description = "사용자들이 작성한 글들을 조회합니다.") + public ResponseEntity>> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ); + + @Operation(summary = "댓글 작성", description = "특정 글에 댓글을 작성합니다.") + public ResponseEntity> createComment(@PathVariable long postId, @RequestBody @Valid CommentRequest.CreateDto req); + + @Operation(summary = "댓글 수정", description = "본인의 댓글을 수정합니다.") + public ResponseEntity> updateComment(@PathVariable long postId, @PathVariable long commentId, @RequestBody @Valid CommentRequest.UpdateDto req); + + @Operation(summary = "댓글 삭제", description = "본인의 댓글을 삭제합니다.") + public ResponseEntity> deleteComment(@PathVariable long postId, @PathVariable long commentId); + + @Operation(summary = "댓글 목록 조회", description = "특정 글에 작성된 댓글들을 조회합니다.") + public ResponseEntity>> listComments( + @PathVariable long postId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size + ); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/dto/CommentRequest.java b/src/main/java/com/project/InsightPrep/domain/post/dto/CommentRequest.java new file mode 100644 index 0000000..ec30377 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/dto/CommentRequest.java @@ -0,0 +1,25 @@ +package com.project.InsightPrep.domain.post.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CommentRequest { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDto { + @NotBlank(message = "댓글 내용을 입력해주세요.") + private String content; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateDto { + @NotBlank(message = "수정할 내용을 입력해주세요.") + private String content; + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/dto/CommentResponse.java b/src/main/java/com/project/InsightPrep/domain/post/dto/CommentResponse.java new file mode 100644 index 0000000..8f6633d --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/dto/CommentResponse.java @@ -0,0 +1,46 @@ +package com.project.InsightPrep.domain.post.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +public class CommentResponse { + + @Getter + @Builder + public static class CommentRes { + private long commentId; + private String content; + private long authorId; + private String authorNickname; + private long postId; + private LocalDateTime createdAt; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class CommentRow { + private long id; + private long postId; + private long memberId; + private String content; + } + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CommentListItem { + private Long commentId; + private Long authorId; + private String authorNickname; + private String content; + private LocalDateTime createdAt; + private boolean mine; + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/dto/PostRequest.java b/src/main/java/com/project/InsightPrep/domain/post/dto/PostRequest.java new file mode 100644 index 0000000..21450b1 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/dto/PostRequest.java @@ -0,0 +1,39 @@ +package com.project.InsightPrep.domain.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +public class PostRequest { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Create { + @NotNull + private Long answerId; + + @NotBlank + @Size(max = 200) + private String title; + + @NotBlank + private String content; + } + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PostOwnerStatusDto { + private Long memberId; + private String status; + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/dto/PostResponse.java b/src/main/java/com/project/InsightPrep/domain/post/dto/PostResponse.java new file mode 100644 index 0000000..8248368 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/dto/PostResponse.java @@ -0,0 +1,62 @@ +package com.project.InsightPrep.domain.post.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +public class PostResponse { + + @Getter + @AllArgsConstructor + public static class Created { + private Long postId; + } + + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class PostDetailDto { + private Long postId; + private String title; + private String content; + private String status; // OPEN / RESOLVED + private LocalDateTime createdAt; + + private Long authorId; + private String authorNickname; + + private Long questionId; + private String category; + private String question; + + private Long answerId; + private String answer; + + private Long feedbackId; + private Integer score; + private String improvement; + private String modelAnswer; + + private Boolean myPost; + private Long commentCount; + } + + @Getter + @Setter + @Builder + public static class PostListItemDto { + private long postId; + private String title; + private LocalDateTime createdAt; + private String status; + private String question; + private String category; + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/exception/PostErrorCode.java b/src/main/java/com/project/InsightPrep/domain/post/exception/PostErrorCode.java new file mode 100644 index 0000000..757ecb3 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/exception/PostErrorCode.java @@ -0,0 +1,25 @@ +package com.project.InsightPrep.domain.post.exception; + +import com.project.InsightPrep.global.common.response.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum PostErrorCode implements BaseErrorCode { + + FORBIDDEN_OR_NOT_FOUND_ANSWER("FORBIDDEN_OR_NOT_FOUND_ANSWER", HttpStatus.BAD_REQUEST, "본인 답변이 아니거나 존재하지 않습니다."), + CREATE_FAILED("CREATE_FAILED", HttpStatus.BAD_REQUEST, "게시글 생성에 실패했습니다."), + POST_NOT_FOUND("POST_NOT_FOUND", HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), + FORBIDDEN("FORBIDDEN", HttpStatus.FORBIDDEN, "본인만 변경이 가능합니다."), + ALREADY_RESOLVED("ALREADY_RESOLVED", HttpStatus.BAD_REQUEST, "이미 해결한 글입니다."), + CONFLICT("CONFLICT", HttpStatus.CONFLICT, "수정에 실패했습니다."), + + COMMENT_NOT_FOUND("COMMENT_NOT_FOUND", HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."), + COMMENT_FORBIDDEN("COMMENT_FORBIDDEN", HttpStatus.FORBIDDEN, "댓글에 대한 권한이 없습니다."); + + private final String code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/exception/PostException.java b/src/main/java/com/project/InsightPrep/domain/post/exception/PostException.java new file mode 100644 index 0000000..b7a1158 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/exception/PostException.java @@ -0,0 +1,13 @@ +package com.project.InsightPrep.domain.post.exception; + +import com.project.InsightPrep.global.common.response.code.BaseErrorCode; +import com.project.InsightPrep.global.exception.CustomException; +import lombok.Getter; + +@Getter +public class PostException extends CustomException { + + public PostException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/mapper/CommentMapper.java b/src/main/java/com/project/InsightPrep/domain/post/mapper/CommentMapper.java new file mode 100644 index 0000000..0869df9 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/mapper/CommentMapper.java @@ -0,0 +1,26 @@ +package com.project.InsightPrep.domain.post.mapper; + +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRow; +import com.project.InsightPrep.domain.post.entity.Comment; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CommentMapper { + + void insertComment(Comment comment); + + Comment findById(@Param("id") long id); + + int updateContent(@Param("id") long id, @Param("memberId") long memberId, @Param("content") String content); + + int deleteByIdAndMember(@Param("id") long id, @Param("memberId") long memberId); + + CommentRow findRowById(@Param("id") long id); + + List findByPostPaged(@Param("postId") long postId, @Param("limit") int limit, @Param("offset") int offset); + + long countByPost(@Param("postId") long postId); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/mapper/SharedPostMapper.java b/src/main/java/com/project/InsightPrep/domain/post/mapper/SharedPostMapper.java new file mode 100644 index 0000000..2e07459 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/mapper/SharedPostMapper.java @@ -0,0 +1,35 @@ +package com.project.InsightPrep.domain.post.mapper; + +import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.post.entity.SharedPost; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface SharedPostMapper { + + int insertSharedPost(@Param("title") String title, + @Param("content") String content, + @Param("answerId") Long answerId, + @Param("memberId") Long memberId, + @Param("status") String status); + + Long lastInsertedId(); + + boolean existsByAnswerId(@Param("answerId") Long answerId); + + PostDetailDto findPostDetailById(@Param("postId") long postId, @Param("viewerId") long viewerId); + + PostOwnerStatusDto findOwnerAndStatus(@Param("postId") long postId); + + int updateStatusToResolved(@Param("postId") long postId); + + List findSharedPostsPaged(@Param("limit") int limit, @Param("offset") int offset); + + long countSharedPosts(); + + SharedPost findById(@Param("postId") long postId); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/CommentService.java b/src/main/java/com/project/InsightPrep/domain/post/service/CommentService.java new file mode 100644 index 0000000..cdc76a8 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/service/CommentService.java @@ -0,0 +1,17 @@ +package com.project.InsightPrep.domain.post.service; + +import com.project.InsightPrep.domain.post.dto.CommentRequest.CreateDto; +import com.project.InsightPrep.domain.post.dto.CommentRequest.UpdateDto; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; + +public interface CommentService { + CommentRes createComment(long postId, CreateDto req); + + void updateComment(long postId, long commentId, UpdateDto req); + + void deleteComment(long postId, long commentId); + + PageResponse getComments(long postId, int page, int size); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/SharedPostService.java b/src/main/java/com/project/InsightPrep/domain/post/service/SharedPostService.java new file mode 100644 index 0000000..b057066 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/service/SharedPostService.java @@ -0,0 +1,16 @@ +package com.project.InsightPrep.domain.post.service; + +import com.project.InsightPrep.domain.post.dto.PostRequest.Create; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; + +public interface SharedPostService { + Long createPost(Create req); + + PostDetailDto getPostDetail(long postId); + + void resolve(long postId); + + PageResponse getPosts(int page, int size); +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java new file mode 100644 index 0000000..6496eb4 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImpl.java @@ -0,0 +1,131 @@ +package com.project.InsightPrep.domain.post.service.impl; + +import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.domain.post.dto.CommentRequest.CreateDto; +import com.project.InsightPrep.domain.post.dto.CommentRequest.UpdateDto; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRow; +import com.project.InsightPrep.domain.post.entity.Comment; +import com.project.InsightPrep.domain.post.entity.SharedPost; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; +import com.project.InsightPrep.domain.post.mapper.CommentMapper; +import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.service.CommentService; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final SecurityUtil securityUtil; + private final SharedPostMapper sharedPostMapper; + private final CommentMapper commentMapper; + + @Override + @Transactional + public CommentRes createComment(long postId, CreateDto req) { + Member me = securityUtil.getAuthenticatedMember(); + + SharedPost post = sharedPostMapper.findById(postId); + if (post == null) { + throw new PostException(PostErrorCode.POST_NOT_FOUND); + } + + Comment comment = Comment.builder() + .content(req.getContent()) + .member(me) + .sharedPost(post) + .build(); + + commentMapper.insertComment(comment); + + return CommentRes.builder() + .commentId(comment.getId()) + .content(comment.getContent()) + .authorId(me.getId()) + .authorNickname(me.getNickname()) + .postId(postId) + .createdAt(LocalDateTime.now()) + .build(); + } + + @Override + @Transactional + public void updateComment(long postId, long commentId, UpdateDto req) { + SharedPost post = sharedPostMapper.findById(postId); + if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + + CommentRow comment = commentMapper.findRowById(commentId); + if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + + // postId 매칭 검증 + if (comment.getPostId() != postId) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + + // 본인이 작성한 댓글 검증 + long me = securityUtil.getLoginMemberId(); + if (comment.getMemberId() != me) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + + int n = commentMapper.updateContent(commentId, me, req.getContent()); + if (n == 0) { + throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + } + } + + @Override + @Transactional + public void deleteComment(long postId, long commentId) { + SharedPost post = sharedPostMapper.findById(postId); + if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + + CommentRow comment = commentMapper.findRowById(commentId); + if (comment == null) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + if (comment.getPostId() != postId) throw new PostException(PostErrorCode.COMMENT_NOT_FOUND); + + long me = securityUtil.getLoginMemberId(); + if (comment.getMemberId() != me) throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + int n = commentMapper.deleteByIdAndMember(commentId, me); + if (n == 0) { + throw new PostException(PostErrorCode.COMMENT_FORBIDDEN); + } + } + + @Override + @Transactional(readOnly = true) + public PageResponse getComments(long postId, int page, int size) { + SharedPost post = sharedPostMapper.findById(postId); + if (post == null) throw new PostException(PostErrorCode.POST_NOT_FOUND); + + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 50); + int offset = (safePage - 1) * safeSize; + + List raw = commentMapper.findByPostPaged(postId, safeSize, offset); + long total = commentMapper.countByPost(postId); + + // 현재 로그인한 사용자 id + long me = securityUtil.getLoginMemberId(); + + List content = raw.stream() + .map(c -> CommentListItem.builder() + .commentId(c.getCommentId()) + .authorId(c.getAuthorId()) + .authorNickname(c.getAuthorNickname()) + .content(c.getContent()) + .createdAt(c.getCreatedAt()) + .mine(c.getAuthorId() != null && c.getAuthorId() == me) + .build()) + .toList(); + + return PageResponse.of(content, safePage, safeSize, total); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java new file mode 100644 index 0000000..166726c --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImpl.java @@ -0,0 +1,93 @@ +package com.project.InsightPrep.domain.post.service.impl; + +import com.project.InsightPrep.domain.post.dto.PostRequest.Create; +import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.post.entity.PostStatus; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; +import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.post.service.SharedPostService; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.mapper.AnswerMapper; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class SharedPostServiceImpl implements SharedPostService { + + private final SecurityUtil securityUtil; + private final SharedPostMapper sharedPostMapper; + private final AnswerMapper answerMapper; + + @Override + @Transactional + public Long createPost(Create req) { + long memberId = securityUtil.getLoginMemberId(); + + boolean myAnswer = answerMapper.existsMyAnswer(req.getAnswerId(), memberId); + if (!myAnswer) { + throw new PostException(PostErrorCode.FORBIDDEN_OR_NOT_FOUND_ANSWER); + } + + int n = sharedPostMapper.insertSharedPost(req.getTitle(), req.getContent(), req.getAnswerId(), memberId, PostStatus.OPEN.name()); + if (n != 1) { + throw new PostException(PostErrorCode.CREATE_FAILED); + } + + return sharedPostMapper.lastInsertedId(); + } + + @Override + @Transactional(readOnly = true) + public PostDetailDto getPostDetail(long postId) { + long viewerId = securityUtil.getLoginMemberId(); + PostDetailDto dto = sharedPostMapper.findPostDetailById(postId, viewerId); + if (dto == null) { + throw new PostException(PostErrorCode.POST_NOT_FOUND); + } + return dto; + } + + @Override + @Transactional + public void resolve(long postId) { + long loginId = securityUtil.getLoginMemberId(); + + PostOwnerStatusDto row = sharedPostMapper.findOwnerAndStatus(postId); + if (row == null) { + throw new PostException(PostErrorCode.POST_NOT_FOUND); + } + if (!row.getMemberId().equals(loginId)) { + throw new PostException(PostErrorCode.FORBIDDEN); + } + if ("RESOLVED".equals(row.getStatus())) { + throw new PostException(PostErrorCode.ALREADY_RESOLVED); + } + + int updated = sharedPostMapper.updateStatusToResolved(postId); + if (updated != 1) { + throw new PostException(PostErrorCode.CONFLICT); + } + } + + @Override + @Transactional(readOnly = true) + public PageResponse getPosts(int page, int size) { + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 50); + int offset = (safePage - 1) * safeSize; + + List content = sharedPostMapper.findSharedPostsPaged(safeSize, offset); + long total = sharedPostMapper.countSharedPosts(); + + return PageResponse.of(content, safePage, safeSize, total); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java index dfa92ae..34c7468 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java +++ b/src/main/java/com/project/InsightPrep/domain/question/controller/QuestionController.java @@ -5,6 +5,7 @@ import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.AnswerDto; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.FeedbackDto; import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.domain.question.service.AnswerService; @@ -79,4 +80,11 @@ public ResponseEntity> deleteQuestion(@PathVariable long answerId answerService.deleteAnswer(answerId); return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.DELETE_QUESTION_SUCCESS)); } + + @GetMapping("/{answerId}/preview") + @PreAuthorize("hasAnyRole('USER')") + public ResponseEntity> getPreview(@PathVariable long answerId) { + PreviewResponse res = answerService.getPreview(answerId); + return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.GET_PREVIEW_SUCCESS, res)); + } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java b/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java index 3210714..a779945 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java +++ b/src/main/java/com/project/InsightPrep/domain/question/controller/docs/QuestionControllerDocs.java @@ -4,6 +4,7 @@ import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse.AnswerDto; import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.global.common.response.ApiResponse; @@ -36,4 +37,7 @@ public ResponseEntity>> getQuestions( @Operation(summary = "특정 면접 질문 삭제", description = "본인이 답변한 질문들을 리스트로 조회했을 때, 원하는 질문에 대하여 삭제합니다. 해당 질문 삭제 시, 질문에 대한 답변과 피드백 모두 삭제됩니다. " + "답변 id로 삭제가 진행되며 피드백이 연쇄 삭제되고, 질문은 상태가 WAITING으로 수정되어 자동으로 삭제됩니다.") public ResponseEntity> deleteQuestion(@PathVariable long answerId); + + @Operation(summary = "답변에 대한 프리뷰 조회", description = "연결된 질문과 답변 조회 시 사용합니다.") + public ResponseEntity> getPreview(@PathVariable long answerId); } diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/PreviewResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/PreviewResponse.java new file mode 100644 index 0000000..befa3f2 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/PreviewResponse.java @@ -0,0 +1,15 @@ +package com.project.InsightPrep.domain.question.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(Include.NON_NULL) +public class PreviewResponse { + + private String question; + private String answer; +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/exception/QuestionErrorCode.java b/src/main/java/com/project/InsightPrep/domain/question/exception/QuestionErrorCode.java index 0e0b74a..4cd18ed 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/exception/QuestionErrorCode.java +++ b/src/main/java/com/project/InsightPrep/domain/question/exception/QuestionErrorCode.java @@ -11,7 +11,8 @@ public enum QuestionErrorCode implements BaseErrorCode { FEEDBACK_NOT_FOUND("FEEDBACK_NOT_FOUND", HttpStatus.NOT_FOUND, "해당 답변의 피드백이 존재하지 않습니다."), QUESTION_NOT_FOUND("QUESTION_NOT_FOUND", HttpStatus.NOT_FOUND, "해당 질문이 존재하지 않습니다."), - ALREADY_DELETED("ALREADY_DELETED", HttpStatus.NOT_FOUND, "이미 삭제되었거나 찾을 수 없습니다."); + ALREADY_DELETED("ALREADY_DELETED", HttpStatus.NOT_FOUND, "이미 삭제되었거나 찾을 수 없습니다."), + ANSWER_NOT_FOUND("ANSWER_NOT_FOUND", HttpStatus.NOT_FOUND, "답변을 찾을 수 없습니다."); private final String code; diff --git a/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java b/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java index b9e7c4d..101ba7b 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java +++ b/src/main/java/com/project/InsightPrep/domain/question/mapper/AnswerMapper.java @@ -1,5 +1,6 @@ package com.project.InsightPrep.domain.question.mapper; +import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.domain.question.entity.Answer; import java.util.List; @@ -21,4 +22,8 @@ public interface AnswerMapper { int deleteMyAnswerById(@Param("answerId") long answerId, @Param("memberId") long memberId); void resetQuestionStatusIfNoAnswers(@Param("questionId") Long questionId, @Param("waiting") String waitingStatus); + + PreviewResponse findMyPreviewByAnswerId(@Param("answerId") long answerId, @Param("memberId") long memberId); + + boolean existsMyAnswer(@Param("answerId") Long answerId, @Param("memberId") Long memberId); } \ No newline at end of file diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java index 78f3d89..08f702e 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/AnswerService.java @@ -2,10 +2,13 @@ import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; +import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; public interface AnswerService { AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId); void deleteAnswer(long answerId); + + PreviewResponse getPreview(long answerId); } \ No newline at end of file diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index a844d40..188f9bb 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -3,6 +3,7 @@ import com.project.InsightPrep.domain.member.entity.Member; import com.project.InsightPrep.domain.question.dto.request.AnswerRequest.AnswerDto; import com.project.InsightPrep.domain.question.dto.response.AnswerResponse; +import com.project.InsightPrep.domain.question.dto.response.PreviewResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; @@ -64,4 +65,15 @@ public void deleteAnswer(long answerId) { answerMapper.resetQuestionStatusIfNoAnswers(questionId, AnswerStatus.WAITING.name()); } + + @Override + @Transactional(readOnly = true) + public PreviewResponse getPreview(long answerId) { + long memberId = securityUtil.getLoginMemberId(); + PreviewResponse res = answerMapper.findMyPreviewByAnswerId(answerId, memberId); + if (res == null) { + throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND); + } + return res; + } } diff --git a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java index f1e43a0..45d2283 100644 --- a/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java +++ b/src/main/java/com/project/InsightPrep/global/common/response/code/ApiSuccessCode.java @@ -22,7 +22,17 @@ public enum ApiSuccessCode { FEEDBACK_PENDING("FEEDBACK_PENDING", HttpStatus.ACCEPTED, "피드백 생성 중입니다."), GET_QUESTIONS_SUCCESS("GET_QUESTIONS_SUCCESS", HttpStatus.OK, "질문 리스트 조회 성공"), DELETE_QUESTION_SUCCESS("DELETE_QUESTION_SUCCESS", HttpStatus.OK, "질문과 답변, 피드백 삭제 성공"), - ME_SUCCESS("ME_SUCCESS", HttpStatus.OK, "로그인 상태입니다."); + ME_SUCCESS("ME_SUCCESS", HttpStatus.OK, "로그인 상태입니다."), + + CREATE_POST_SUCCESS("CREATE_POST_SUCCESS", HttpStatus.OK, "글 생성 성공"), + GET_POST_SUCCESS("GET_POST_SUCCESS", HttpStatus.OK, "글 조회 성공"), + UPDATE_POST_STATUS_SUCCESS("UPDATE_POST_STATUS_SUCCESS", HttpStatus.OK, "글 상태 변경 성공"), + GET_POSTS_SUCCESS("GET_POSTS_SUCCESS", HttpStatus.OK, "글 리스트 조회 성공"), + CREATE_COMMENT_SUCCESS("CREATE_COMMENT_SUCCESS", HttpStatus.OK, "댓글 저장 성공"), + UPDATE_COMMENT_SUCCESS("UPDATE_COMMENT_SUCCESS", HttpStatus.OK, "댓글 수정 성공"), + DELETE_COMMENT_SUCCESS("DELETE_COMMENT_SUCCESS", HttpStatus.OK, "댓글 삭제 성공"), + GET_COMMENTS_SUCCESS("GET_COMMENTS_SUCCESS", HttpStatus.OK, "댓글 리스트 조회 성공"), + GET_PREVIEW_SUCCESS("GET_PREVIEW_SUCCESS", HttpStatus.OK, "프리뷰 조회 성공"); private final String code; private final HttpStatus status; diff --git a/src/main/resources/mapper/answer-mapper.xml b/src/main/resources/mapper/answer-mapper.xml index 47eabcb..76554aa 100644 --- a/src/main/resources/mapper/answer-mapper.xml +++ b/src/main/resources/mapper/answer-mapper.xml @@ -89,4 +89,22 @@ ) + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/comment-mapper.xml b/src/main/resources/mapper/comment-mapper.xml new file mode 100644 index 0000000..5d806b5 --- /dev/null +++ b/src/main/resources/mapper/comment-mapper.xml @@ -0,0 +1,77 @@ + + + + + + + + SELECT currval(pg_get_serial_sequence('comment','id')) + + INSERT INTO comment (created_at, updated_at, content, member_id, post_id) + VALUES (NOW(), NOW(), #{content}, #{member.id}, #{sharedPost.id}) + + + + + + + + + + + + + UPDATE comment + SET content = #{content}, + updated_at = NOW() + WHERE id = #{id} + AND member_id = #{memberId} + + + + DELETE FROM comment + WHERE id = #{id} + AND member_id = #{memberId} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/shared-post-mapper.xml b/src/main/resources/mapper/shared-post-mapper.xml new file mode 100644 index 0000000..e123bb7 --- /dev/null +++ b/src/main/resources/mapper/shared-post-mapper.xml @@ -0,0 +1,148 @@ + + + + + + + + SELECT currval(pg_get_serial_sequence('shared_post','id')) + + INSERT INTO shared_post ( + created_at, updated_at, title, content, answer_id, member_id, status + ) VALUES ( + NOW(), NOW(), #{title}, #{content}, #{answerId}, #{memberId}, #{status} + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE shared_post + SET status = 'RESOLVED', updated_at = NOW() + WHERE id = #{postId} AND status = 'OPEN' + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql-map-config.xml b/src/main/resources/sql-map-config.xml index 8ed36ad..2d7ec4b 100644 --- a/src/main/resources/sql-map-config.xml +++ b/src/main/resources/sql-map-config.xml @@ -10,5 +10,7 @@ + + \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java b/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java new file mode 100644 index 0000000..eac5a00 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java @@ -0,0 +1,217 @@ +package com.project.InsightPrep.domain.post.controller; + +import static javax.swing.text.html.HTML.Tag.BASE; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.InsightPrep.domain.post.dto.CommentRequest; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; +import com.project.InsightPrep.domain.post.service.CommentService; +import com.project.InsightPrep.domain.post.service.SharedPostService; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.global.common.response.code.ApiSuccessCode; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PostController.class) +class CommentControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + CommentService commentService; + + @MockitoBean + SharedPostService sharedPostService; + + @Test + @DisplayName("POST /post/{postId}/comments - 성공") + @WithMockUser(roles = "USER") + void createComment_success() throws Exception { + long postId = 10L; + + var req = new CommentRequest.CreateDto("첫 댓글"); + var res = CommentRes.builder() + .commentId(777L) + .content("첫 댓글") + .authorId(1L) + .authorNickname("tester") + .postId(postId) + .build(); + + when(commentService.createComment(eq(postId), ArgumentMatchers.any(CommentRequest.CreateDto.class))) + .thenReturn(res); + + mockMvc.perform(post("/post/{postId}/comments", postId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.commentId").value(777L)) + .andExpect(jsonPath("$.result.content").value("첫 댓글")) + .andExpect(jsonPath("$.result.postId").value((int) postId)) + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + + verify(commentService).createComment(eq(postId), ArgumentMatchers.any(CommentRequest.CreateDto.class)); + verifyNoMoreInteractions(commentService); + } + + @Test + @DisplayName("PUT /post/{postId}/comments/{commentId} - 성공") + @WithMockUser(roles = "USER") + void updateComment_success() throws Exception { + long postId = 10L; + long commentId = 200L; + + var req = new CommentRequest.UpdateDto("수정된 내용"); + + doNothing().when(commentService).updateComment(eq(postId), eq(commentId), ArgumentMatchers.any(CommentRequest.UpdateDto.class)); + + mockMvc.perform(put("/post/{postId}/comments/{commentId}", postId, commentId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + + verify(commentService).updateComment(eq(postId), eq(commentId), ArgumentMatchers.any(CommentRequest.UpdateDto.class)); + verifyNoMoreInteractions(commentService); + } + + @Test + @DisplayName("DELETE /post/{postId}/comments/{commentId} - 성공") + @WithMockUser(roles = "USER") + void deleteComment_success() throws Exception { + long postId = 10L; + long commentId = 200L; + + doNothing().when(commentService).deleteComment(eq(postId), eq(commentId)); + + mockMvc.perform(delete("/post/{postId}/comments/{commentId}", postId, commentId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + + verify(commentService).deleteComment(eq(postId), eq(commentId)); + verifyNoMoreInteractions(commentService); + } + + @Test + @DisplayName("댓글 목록 조회 성공 - 기본 파라미터(page=1,size=10)와 응답 구조 검증") + void listComments_success_defaultParams() throws Exception { + long postId = 1L; + int page = 1; + int size = 10; + + List items = List.of( + CommentListItem.builder() + .commentId(101L) + .authorId(11L) + .authorNickname("alice") + .content("first") + .createdAt(LocalDateTime.now().minusMinutes(2)) + .mine(false) + .build(), + CommentListItem.builder() + .commentId(102L) + .authorId(22L) + .authorNickname("bob") + .content("second") + .createdAt(LocalDateTime.now().minusMinutes(1)) + .mine(true) + .build() + ); + PageResponse pageRes = PageResponse.of(items, page, size, 2L); + + when(commentService.getComments(eq(postId), eq(page), eq(size))) + .thenReturn(pageRes); + + mockMvc.perform(get("/post/{postId}/comments", postId) + .with(csrf()) + .with(user("u1").roles("USER")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + // ApiResponse 공통 래퍼 구조 검증 + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.message", not(isEmptyOrNullString()))) + // 페이징 필드 + .andExpect(jsonPath("$.result.page").value(page)) + .andExpect(jsonPath("$.result.size").value(size)) + .andExpect(jsonPath("$.result.totalElements").value(2)) + // 콘텐츠 일부 필드 검증 + .andExpect(jsonPath("$.result.content", hasSize(2))) + .andExpect(jsonPath("$.result.content[0].commentId").value(101)) + .andExpect(jsonPath("$.result.content[0].authorNickname").value("alice")) + .andExpect(jsonPath("$.result.content[0].mine").value(false)) + .andExpect(jsonPath("$.result.content[1].commentId").value(102)) + .andExpect(jsonPath("$.result.content[1].authorNickname").value("bob")) + .andExpect(jsonPath("$.result.content[1].mine").value(true)); + } + + @Test + @DisplayName("댓글 목록 조회 성공 - 커스텀 파라미터(page,size) 반영") + void listComments_success_customParams() throws Exception { + long postId = 2L; + int page = 3; + int size = 5; + + List items = List.of(); // 빈 페이지 예시 + PageResponse pageRes = PageResponse.of(items, page, size, 0L); + + when(commentService.getComments(eq(postId), eq(page), eq(size))) + .thenReturn(pageRes); + + mockMvc.perform(get("/post/{postId}/comments", postId) + .with(csrf()) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .with(user("u2").roles("USER")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.result.page").value(page)) + .andExpect(jsonPath("$.result.size").value(size)) + .andExpect(jsonPath("$.result.totalElements").value(0)) + .andExpect(jsonPath("$.result.content", hasSize(0))); + } + + @Test + @DisplayName("게시글이 없으면 POST_NOT_FOUND 에러 응답") + void listComments_postNotFound() throws Exception { + long postId = 404L; + + when(commentService.getComments(eq(postId), eq(1), eq(10))) + .thenThrow(new PostException(PostErrorCode.POST_NOT_FOUND)); + + mockMvc.perform(get("/post/{postId}/comments", postId) + .with(csrf()) + .with(user("u3").roles("USER")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PostErrorCode.POST_NOT_FOUND.name())) + .andExpect(jsonPath("$.message", containsString(PostErrorCode.POST_NOT_FOUND.getMessage()))); + } +} \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java b/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java new file mode 100644 index 0000000..38fa054 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java @@ -0,0 +1,154 @@ +package com.project.InsightPrep.domain.post.controller; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.InsightPrep.domain.post.dto.PostRequest; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.post.service.CommentService; +import com.project.InsightPrep.domain.post.service.SharedPostService; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PostController.class) +class PostControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + SharedPostService sharedPostService; + + @MockitoBean + CommentService commentService; + + @Test + @WithMockUser(roles = "USER") + @DisplayName("POST /api/posts : 글 생성 성공") + void createPost_success() throws Exception { + PostRequest.Create req = PostRequest.Create.builder() + .title("제목") + .content("본문") + .answerId(123L) + .build(); + + given(sharedPostService.createPost(ArgumentMatchers.any(PostRequest.Create.class))) + .willReturn(777L); + + mockMvc.perform(post("/post") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("CREATE_POST_SUCCESS")) + .andExpect(jsonPath("$.result.postId").value(777L)); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("GET /api/posts/{postId} : 단건 조회 성공") + void getPost_success() throws Exception { + long postId = 7L; + PostDetailDto dto = PostDetailDto.builder() + .postId(postId) + .title("제목") + .content("본문") + .status("OPEN") + .createdAt(LocalDateTime.now()) + .authorId(10L) + .authorNickname("작성자") + .questionId(1L) + .category("CS") + .question("질문") + .answerId(2L) + .answer("답변") + .feedbackId(null) + .score(null) + .improvement(null) + .modelAnswer(null) + .myPost(true) + .commentCount(3L) + .build(); + + given(sharedPostService.getPostDetail(postId)).willReturn(dto); + + mockMvc.perform(get("/post/{postId}", postId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GET_POST_SUCCESS")) + .andExpect(jsonPath("$.result.postId").value((int) postId)) + .andExpect(jsonPath("$.result.title").value("제목")) + .andExpect(jsonPath("$.result.status").value("OPEN")); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("PATCH /api/posts/{postId}/resolve : 해결 처리 성공") + void resolve_success() throws Exception { + long postId = 5L; + // void 메서드이므로 별도 stubbing 불필요 (예외 없음 가정) + + mockMvc.perform(patch("/post/{postId}/resolve", postId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.result").doesNotExist()); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("GET /api/posts : 목록 + 페이징 성공") + void list_success() throws Exception { + int page = 2, size = 3; + + List items = List.of( + PostListItemDto.builder() + .postId(101L).title("T1").status("OPEN") + .createdAt(LocalDateTime.now()) + .question("Q1").category("CS") + .build(), + PostListItemDto.builder() + .postId(102L).title("T2").status("RESOLVED") + .createdAt(LocalDateTime.now()) + .question("Q2").category("Algorithm") + .build() + ); + + PageResponse pageRes = PageResponse.of(items, page, size, 20L); + + given(sharedPostService.getPosts(page, size)).willReturn(pageRes); + + mockMvc.perform(get("/post") + .with(csrf()) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.result.page").value(page)) + .andExpect(jsonPath("$.result.size").value(size)) + .andExpect(jsonPath("$.result.totalElements").value(20)) + .andExpect(jsonPath("$.result.content", hasSize(2))) + .andExpect(jsonPath("$.result.content[0].title").value("T1")) + .andExpect(jsonPath("$.result.content[1].status").value("RESOLVED")); + } +} \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java new file mode 100644 index 0000000..a49de9c --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java @@ -0,0 +1,479 @@ +package com.project.InsightPrep.domain.post.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.project.InsightPrep.domain.member.entity.Member; +import com.project.InsightPrep.domain.post.dto.CommentRequest.CreateDto; +import com.project.InsightPrep.domain.post.dto.CommentRequest.UpdateDto; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentListItem; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRes; +import com.project.InsightPrep.domain.post.dto.CommentResponse.CommentRow; +import com.project.InsightPrep.domain.post.entity.Comment; +import com.project.InsightPrep.domain.post.entity.SharedPost; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; +import com.project.InsightPrep.domain.post.mapper.CommentMapper; +import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +class CommentServiceImplTest { + + @Mock + SecurityUtil securityUtil; + + @Mock + SharedPostMapper sharedPostMapper; + + @Mock + CommentMapper commentMapper; + + @InjectMocks + CommentServiceImpl commentService; + + private static SharedPost stubPost(long id) { + return SharedPost.builder().id(id).build(); + } + + private static CommentListItem item(long cid, long authorId, String nickname, String content, LocalDateTime ts) { + return CommentListItem.builder() + .commentId(cid) + .authorId(authorId) + .authorNickname(nickname) + .content(content) + .createdAt(ts) + // service에서 mine 세팅하므로 여기선 넣지 않음 + .build(); + } + + // ===== createComment ===== + @Nested + @DisplayName("createComment") + class CreateComment { + + @Test + @DisplayName("성공 - 게시글 존재 + 본인 인증 OK") + void create_success() { + long postId = 10L; + Member me = Member.builder().id(1L).nickname("tester").build(); + SharedPost post = SharedPost.builder().id(postId).build(); + + when(securityUtil.getAuthenticatedMember()).thenReturn(me); + when(sharedPostMapper.findById(postId)).thenReturn(post); + // insert 시 selectKey로 id가 세팅되는 것을 흉내 + doAnswer(inv -> { + Comment c = inv.getArgument(0); + try { + var idField = Comment.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(c, 777L); + } catch (Exception ignored) {} + return null; + }).when(commentMapper).insertComment(any(Comment.class)); + + CreateDto req = new CreateDto("첫 댓글"); + + CommentRes res = commentService.createComment(postId, req); + + assertThat(res.getCommentId()).isEqualTo(777L); + assertThat(res.getContent()).isEqualTo("첫 댓글"); + assertThat(res.getAuthorId()).isEqualTo(1L); + assertThat(res.getAuthorNickname()).isEqualTo("tester"); + assertThat(res.getPostId()).isEqualTo(postId); + + verify(securityUtil).getAuthenticatedMember(); + verify(sharedPostMapper).findById(postId); + verify(commentMapper).insertComment(any(Comment.class)); + } + + @Test + @DisplayName("실패 - 게시글 없음 → POST_NOT_FOUND") + void create_post_not_found() { + long postId = 99L; + when(sharedPostMapper.findById(postId)).thenReturn(null); + + assertThatThrownBy(() -> + commentService.createComment(postId, new CreateDto("x")) + ).isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verifyNoInteractions(commentMapper); + } + } + + // ===== updateComment ===== + @Nested + @DisplayName("updateComment") + class UpdateComment { + + @Test + @DisplayName("성공 - 본인 댓글 & 같은 postId") + void update_success() { + long postId = 10L; + long commentId = 200L; + long me = 1L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, postId, me, "old")); + when(securityUtil.getLoginMemberId()).thenReturn(me); + when(commentMapper.updateContent(commentId, me, "new content")).thenReturn(1); + + commentService.updateComment(postId, commentId, new UpdateDto("new content")); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verify(securityUtil).getLoginMemberId(); + verify(commentMapper).updateContent(commentId, me, "new content"); + } + + @Test + @DisplayName("실패 - 게시글 없음 → POST_NOT_FOUND") + void update_post_not_found() { + long postId = 10L; + long commentId = 200L; + when(sharedPostMapper.findById(postId)).thenReturn(null); + + assertThatThrownBy(() -> + commentService.updateComment(postId, commentId, new UpdateDto("x")) + ).isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verifyNoMoreInteractions(sharedPostMapper); + verifyNoInteractions(commentMapper, securityUtil); + } + + @Test + @DisplayName("실패 - 댓글 없음 → COMMENT_NOT_FOUND") + void update_comment_not_found() { + long postId = 10L; + long commentId = 200L; + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)).thenReturn(null); + + assertThatThrownBy(() -> + commentService.updateComment(postId, commentId, new UpdateDto("x")) + ).isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verifyNoMoreInteractions(commentMapper); + verifyNoInteractions(securityUtil); + } + + @Test + @DisplayName("실패 - 다른 게시글의 댓글 → COMMENT_NOT_FOUND") + void update_wrong_postId() { + long postId = 10L; + long commentId = 200L; + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + // 댓글이 다른 postId에 속함 + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); + + assertThatThrownBy(() -> + commentService.updateComment(postId, commentId, new UpdateDto("x")) + ).isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verifyNoMoreInteractions(commentMapper); + verifyNoInteractions(securityUtil); + } + + @Test + @DisplayName("실패 - 본인 아님 → COMMENT_FORBIDDEN") + void update_forbidden() { + long postId = 10L; + long commentId = 200L; + long owner = 1L; + long me = 2L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, postId, owner, "x")); + when(securityUtil.getLoginMemberId()).thenReturn(me); + + assertThatThrownBy(() -> + commentService.updateComment(postId, commentId, new UpdateDto("new")) + ).isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verify(securityUtil).getLoginMemberId(); + verifyNoMoreInteractions(commentMapper); + } + } + + // ===== deleteComment ===== + @Nested + @DisplayName("deleteComment") + class DeleteComment { + + @Test + @DisplayName("성공 - 본인 댓글 삭제") + void delete_success() { + long postId = 10L; + long commentId = 200L; + long me = 1L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, postId, me, "x")); + when(securityUtil.getLoginMemberId()).thenReturn(me); + when(commentMapper.deleteByIdAndMember(commentId, me)).thenReturn(1); + + commentService.deleteComment(postId, commentId); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verify(securityUtil).getLoginMemberId(); + verify(commentMapper).deleteByIdAndMember(commentId, me); + } + + @Test + @DisplayName("실패 - 게시글 없음 → POST_NOT_FOUND") + void delete_post_not_found() { + long postId = 10L; + long commentId = 200L; + + when(sharedPostMapper.findById(postId)).thenReturn(null); + + assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verifyNoInteractions(commentMapper, securityUtil); + } + + @Test + @DisplayName("실패 - 댓글 없음 → COMMENT_NOT_FOUND") + void delete_comment_not_found() { + long postId = 10L; + long commentId = 200L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)).thenReturn(null); + + assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verifyNoMoreInteractions(commentMapper); + verifyNoInteractions(securityUtil); + } + + @Test + @DisplayName("실패 - 다른 게시글의 댓글 → COMMENT_NOT_FOUND") + void delete_wrong_postId() { + long postId = 10L; + long commentId = 200L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, 999L, 1L, "x")); + + assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verifyNoMoreInteractions(commentMapper); + verifyNoInteractions(securityUtil); + } + + @Test + @DisplayName("실패 - 본인 아님 → COMMENT_FORBIDDEN (현재 구현상 delete 쿼리는 수행됨)") + void delete_forbidden() { + long postId = 10L; + long commentId = 200L; + long owner = 1L; + long me = 2L; + + when(sharedPostMapper.findById(postId)).thenReturn(SharedPost.builder().id(postId).build()); + when(commentMapper.findRowById(commentId)) + .thenReturn(new CommentRow(commentId, postId, owner, "x")); + when(securityUtil.getLoginMemberId()).thenReturn(me); + // 구현상 먼저 deleteByIdAndMember를 호출한 뒤 소유자 검사 → 0 반환될 수 있음 + when(commentMapper.deleteByIdAndMember(commentId, me)).thenReturn(0); + + assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.COMMENT_FORBIDDEN.getMessage()); + + verify(sharedPostMapper).findById(postId); + verify(commentMapper).findRowById(commentId); + verify(securityUtil).getLoginMemberId(); + verify(commentMapper).deleteByIdAndMember(commentId, me); + } + } + + @Nested + @DisplayName("예외 케이스") + class ExceptionCases { + + @Test + @DisplayName("게시글이 없으면 POST_NOT_FOUND 발생") + void post_not_found() { + long postId = 999L; + when(sharedPostMapper.findById(postId)).thenReturn(null); + + assertThatThrownBy(() -> commentService.getComments(postId, 1, 10)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + + verify(sharedPostMapper).findById(postId); + verifyNoMoreInteractions(commentMapper, securityUtil); + } + } + + @Nested + @DisplayName("정상 케이스") + class SuccessCases { + + @BeforeEach + void setUp() { + // 공통: 게시글 존재 + when(sharedPostMapper.findById(1L)).thenReturn(stubPost(1L)); + } + + @Test + @DisplayName("기본 페이징(page=1,size=10)과 mine 매핑 검증") + void basic_paging_and_mine_mapping() { + long postId = 1L; + int page = 1; + int size = 10; + long me = 10L; + + // 댓글 더미(한 개는 내가 쓴 댓글, 한 개는 타인 댓글) + var now = LocalDateTime.now(); + List dbRows = List.of( + item(101L, 10L, "me", "my comment", now.minusMinutes(2)), + item(102L, 20L, "u", "your comment", now.minusMinutes(1)) + ); + when(commentMapper.findByPostPaged(postId, size, 0)).thenReturn(dbRows); + when(commentMapper.countByPost(postId)).thenReturn(2L); + when(securityUtil.getLoginMemberId()).thenReturn(me); + + PageResponse pageRes = commentService.getComments(postId, page, size); + + // 반환 검증 + assertThat(pageRes.getPage()).isEqualTo(1); + assertThat(pageRes.getSize()).isEqualTo(10); + assertThat(pageRes.getTotalElements()).isEqualTo(2L); + assertThat(pageRes.getContent()).hasSize(2); + + // mine 플래그 검증 + assertThat(pageRes.getContent().get(0).getCommentId()).isEqualTo(101L); + assertThat(pageRes.getContent().get(0).isMine()).isTrue(); + + assertThat(pageRes.getContent().get(1).getCommentId()).isEqualTo(102L); + assertThat(pageRes.getContent().get(1).isMine()).isFalse(); + + // 호출 파라미터 검증 + verify(commentMapper).findByPostPaged(postId, size, 0); + verify(commentMapper).countByPost(postId); + verify(securityUtil).getLoginMemberId(); + } + + @Test + @DisplayName("page<1 이면 1로 보정, size>50 이면 50으로 보정하여 limit/offset 계산") + void page_and_size_sanitization() { + long postId = 1L; + int reqPage = 0; // 보정 대상 + int reqSize = 100; // 보정 대상(최대 50) + int safePage = 1; + int safeSize = 50; + int expectedOffset = 0; + + when(securityUtil.getLoginMemberId()).thenReturn(999L); + when(commentMapper.findByPostPaged(postId, safeSize, expectedOffset)).thenReturn(List.of()); + when(commentMapper.countByPost(postId)).thenReturn(0L); + + PageResponse pageRes = commentService.getComments(postId, reqPage, reqSize); + + assertThat(pageRes.getPage()).isEqualTo(safePage); + assertThat(pageRes.getSize()).isEqualTo(safeSize); + assertThat(pageRes.getTotalElements()).isZero(); + assertThat(pageRes.getContent()).isEmpty(); + + // limit/offset 정확히 호출되었는지 캡쳐로 재확인 + ArgumentCaptor limitCap = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor offsetCap = ArgumentCaptor.forClass(Integer.class); + verify(commentMapper).findByPostPaged(eq(postId), limitCap.capture(), offsetCap.capture()); + assertThat(limitCap.getValue()).isEqualTo(safeSize); + assertThat(offsetCap.getValue()).isEqualTo(expectedOffset); + } + + @Test + @DisplayName("page가 2 이상이면 올바른 offset 계산((page-1)*size)") + void offset_calculation_when_page_gt_1() { + long postId = 1L; + int page = 3; + int size = 20; + int expectedOffset = (page - 1) * size; // 40 + + when(securityUtil.getLoginMemberId()).thenReturn(1L); + when(commentMapper.findByPostPaged(postId, size, expectedOffset)).thenReturn(List.of()); + when(commentMapper.countByPost(postId)).thenReturn(0L); + + commentService.getComments(postId, page, size); + + verify(commentMapper).findByPostPaged(postId, size, expectedOffset); + } + + @Test + @DisplayName("DB가 null authorId를 반환해도 NPE 없이 mine=false 처리") + void null_author_id_safe_mine_false() { + long postId = 1L; + long me = 7L; + + CommentListItem row = CommentListItem.builder() + .commentId(1L) + .authorId(null) // 의도적으로 null + .authorNickname("anon") + .content("hi") + .createdAt(LocalDateTime.now()) + .build(); + + when(commentMapper.findByPostPaged(postId, 10, 0)).thenReturn(List.of(row)); + when(commentMapper.countByPost(postId)).thenReturn(1L); + when(securityUtil.getLoginMemberId()).thenReturn(me); + + PageResponse res = commentService.getComments(postId, 1, 10); + + assertThat(res.getContent()).hasSize(1); + assertThat(res.getContent().get(0).isMine()).isFalse(); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java new file mode 100644 index 0000000..66647b0 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/SharedPostServiceImplTest.java @@ -0,0 +1,293 @@ +package com.project.InsightPrep.domain.post.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.project.InsightPrep.domain.post.dto.PostRequest.Create; +import com.project.InsightPrep.domain.post.dto.PostRequest.PostOwnerStatusDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostDetailDto; +import com.project.InsightPrep.domain.post.dto.PostResponse.PostListItemDto; +import com.project.InsightPrep.domain.post.entity.PostStatus; +import com.project.InsightPrep.domain.post.exception.PostErrorCode; +import com.project.InsightPrep.domain.post.exception.PostException; +import com.project.InsightPrep.domain.post.mapper.SharedPostMapper; +import com.project.InsightPrep.domain.question.dto.response.PageResponse; +import com.project.InsightPrep.domain.question.mapper.AnswerMapper; +import com.project.InsightPrep.global.auth.util.SecurityUtil; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SharedPostServiceImplTest { + + @Mock + SecurityUtil securityUtil; + + @Mock + SharedPostMapper sharedPostMapper; + + @Mock + AnswerMapper answerMapper; + + @InjectMocks + SharedPostServiceImpl service; + + @Test + @DisplayName("createPost: 성공") + void createPost_success() { + long memberId = 10L; + long answerId = 111L; + + Create req = Create.builder() + .title("t") + .content("c") + .answerId(answerId) + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(memberId); + given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(true); + given(sharedPostMapper.insertSharedPost(eq("t"), eq("c"), eq(answerId), eq(memberId), eq(PostStatus.OPEN.name()))) + .willReturn(1); + given(sharedPostMapper.lastInsertedId()).willReturn(999L); + + Long id = service.createPost(req); + + assertThat(id).isEqualTo(999L); + verify(sharedPostMapper).insertSharedPost("t", "c", answerId, memberId, PostStatus.OPEN.name()); + verify(sharedPostMapper).lastInsertedId(); + } + + @Test + @DisplayName("createPost: 내 답변이 아니면 FORBIDDEN_OR_NOT_FOUND_ANSWER") + void createPost_forbiddenOrNotFoundAnswer() { + long memberId = 10L; + long answerId = 111L; + + Create req = Create.builder() + .title("t") + .content("c") + .answerId(answerId) + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(memberId); + given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(false); + + assertThatThrownBy(() -> service.createPost(req)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.FORBIDDEN_OR_NOT_FOUND_ANSWER.getMessage()); + + verify(sharedPostMapper, never()).insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString()); + } + + @Test + @DisplayName("createPost: insert 실패 시 CREATE_FAILED") + void createPost_createFailed() { + long memberId = 10L; + long answerId = 111L; + + Create req = Create.builder() + .title("t") + .content("c") + .answerId(answerId) + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(memberId); + given(answerMapper.existsMyAnswer(answerId, memberId)).willReturn(true); + given(sharedPostMapper.insertSharedPost(anyString(), anyString(), anyLong(), anyLong(), anyString())) + .willReturn(0); + + assertThatThrownBy(() -> service.createPost(req)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.CREATE_FAILED.getMessage()); + } + + @Test + @DisplayName("getPostDetail: 성공") + void getPostDetail_success() { + long postId = 7L; + long viewerId = 10L; + + PostDetailDto dto = PostDetailDto.builder() + .postId(postId) + .title("t") + .content("c") + .status(PostStatus.OPEN.name()) + .createdAt(LocalDateTime.now()) + .authorId(10L) + .authorNickname("me") + .questionId(1L) + .category("CS") + .question("Q?") + .answerId(111L) + .answer("A") + .feedbackId(null) + .score(null) + .improvement(null) + .modelAnswer(null) + // 필요 시 서비스에서 채워지는 필드가 있다면 거기에 맞춰 준다 + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(viewerId); + given(sharedPostMapper.findPostDetailById(postId, viewerId)).willReturn(dto); + + PostDetailDto res = service.getPostDetail(postId); + + assertThat(res.getPostId()).isEqualTo(postId); + verify(sharedPostMapper).findPostDetailById(postId, viewerId); + } + + @Test + @DisplayName("getPostDetail: 게시글 없음 → POST_NOT_FOUND") + void getPostDetail_notFound() { + long postId = 7L; + long viewerId = 10L; + + given(securityUtil.getLoginMemberId()).willReturn(viewerId); + given(sharedPostMapper.findPostDetailById(postId, viewerId)).willReturn(null); + + assertThatThrownBy(() -> service.getPostDetail(postId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("resolve: 성공") + void resolve_success() { + long postId = 5L; + long loginId = 10L; + + PostOwnerStatusDto row = PostOwnerStatusDto.builder() + .memberId(loginId) + .status("OPEN") + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(loginId); + given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + given(sharedPostMapper.updateStatusToResolved(postId)).willReturn(1); + + service.resolve(postId); + + verify(sharedPostMapper).updateStatusToResolved(postId); + } + + @Test + @DisplayName("resolve: 게시글 없음 → POST_NOT_FOUND") + void resolve_notFound() { + long postId = 5L; + long loginId = 10L; + + given(securityUtil.getLoginMemberId()).willReturn(loginId); + given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(null); + + assertThatThrownBy(() -> service.resolve(postId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.POST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("resolve: 본인 글 아님 → FORBIDDEN") + void resolve_forbidden() { + long postId = 5L; + long loginId = 10L; + + PostOwnerStatusDto row = PostOwnerStatusDto.builder() + .memberId(999L) // 다른 사람 + .status("OPEN") + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(loginId); + given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + + assertThatThrownBy(() -> service.resolve(postId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.FORBIDDEN.getMessage()); + } + + @Test + @DisplayName("resolve: 이미 RESOLVED → ALREADY_RESOLVED") + void resolve_alreadyResolved() { + long postId = 5L; + long loginId = 10L; + + PostOwnerStatusDto row = PostOwnerStatusDto.builder() + .memberId(loginId) + .status("RESOLVED") + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(loginId); + given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + + assertThatThrownBy(() -> service.resolve(postId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.ALREADY_RESOLVED.getMessage()); + } + + @Test + @DisplayName("resolve: 업데이트 실패 → CONFLICT") + void resolve_conflict() { + long postId = 5L; + long loginId = 10L; + + PostOwnerStatusDto row = PostOwnerStatusDto.builder() + .memberId(loginId) + .status("OPEN") + .build(); + + given(securityUtil.getLoginMemberId()).willReturn(loginId); + given(sharedPostMapper.findOwnerAndStatus(postId)).willReturn(row); + given(sharedPostMapper.updateStatusToResolved(postId)).willReturn(0); + + assertThatThrownBy(() -> service.resolve(postId)) + .isInstanceOf(PostException.class) + .hasMessageContaining(PostErrorCode.CONFLICT.getMessage()); + } + + @Test + @DisplayName("getPosts: 정상 페이징(보정 포함)과 결과 매핑") + void getPosts_success() { + // page=0, size=1000 들어와도 보정: page=1, size=50 + int reqPage = 0; + int reqSize = 1000; + int safePage = 1; + int safeSize = 50; + int offset = 0; + + List rows = List.of( + PostListItemDto.builder() + .postId(1L) + .title("T1") + .status("OPEN") + .createdAt(LocalDateTime.now()) + .question("Q1") + .category("CS") + .build() + ); + + given(sharedPostMapper.findSharedPostsPaged(safeSize, offset)).willReturn(rows); + given(sharedPostMapper.countSharedPosts()).willReturn(1L); + + PageResponse res = service.getPosts(reqPage, reqSize); + + assertThat(res.getPage()).isEqualTo(safePage); + assertThat(res.getSize()).isEqualTo(safeSize); + assertThat(res.getTotalElements()).isEqualTo(1L); + assertThat(res.getContent()).hasSize(1); + assertThat(res.getContent().get(0).getTitle()).isEqualTo("T1"); + + verify(sharedPostMapper).findSharedPostsPaged(safeSize, offset); + verify(sharedPostMapper).countSharedPosts(); + } +} \ No newline at end of file