diff --git a/back/src/main/java/com/back/domain/comment/controller/CommentController.java b/back/src/main/java/com/back/domain/comment/controller/CommentController.java index ce82570..0fbf3a0 100644 --- a/back/src/main/java/com/back/domain/comment/controller/CommentController.java +++ b/back/src/main/java/com/back/domain/comment/controller/CommentController.java @@ -1,13 +1,26 @@ package com.back.domain.comment.controller; +import com.back.domain.comment.dto.CommentRequest; +import com.back.domain.comment.dto.CommentResponse; +import com.back.domain.comment.enums.CommentSortType; import com.back.domain.comment.service.CommentService; +import com.back.global.common.PageResponse; +import com.back.global.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +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.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; -/** - * 댓글 관련 API 요청을 처리하는 컨트롤러. - */ +@Tag(name = "Comment", description = "댓글 관련 API") @RestController @RequestMapping("/api/v1/posts/{postId}/comments") @RequiredArgsConstructor @@ -15,4 +28,63 @@ public class CommentController { private final CommentService commentService; + // 댓글 생성 + @PostMapping + @Operation(summary = "댓글 생성", description = "새 댓글을 생성합니다.") + public ResponseEntity createPost( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "생성할 댓글 정보", + required = true + ) + @RequestBody @Valid CommentRequest request, + @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, + @AuthenticationPrincipal CustomUserDetails cs + ) { + CommentResponse response = commentService.createComment(cs.getUser().getId(), postId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + // fixme 게시글 목록 조회 - 정렬 조건 최신순, 좋아요순 추가하였는데 변경될 수 있음 + @GetMapping + @Operation(summary = "댓글 목록 조회", description = "게시글 목록을 조회합니다.") + public ResponseEntity> getPosts( + @Parameter(description = "페이지 정보") Pageable pageable, + @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, + @Parameter(description = "정렬 조건 LATEST or LIKES") @RequestParam(defaultValue = "LATEST") CommentSortType sortType, + @AuthenticationPrincipal CustomUserDetails cs) { + + Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty()); + + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + sort + ); + + Page responses = commentService.getComments(cs.getUser().getId(), postId, sortedPageable); + return ResponseEntity.ok(PageResponse.of(responses)); + } + + + @PutMapping("/{commentId}") + @Operation(summary = "댓글 수정", description = "자신의 댓글을 수정합니다.") + public ResponseEntity updateComment( + @Parameter(description = "수정할 댓글 ID", required = true) @PathVariable Long commentId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "수정할 댓글 정보", + required = true + ) + @RequestBody @Valid CommentRequest request, + @AuthenticationPrincipal CustomUserDetails cs) { + return ResponseEntity.ok(commentService.updateComment(cs.getUser().getId(), commentId, request)); + } + + @DeleteMapping("/{commentId}") + @Operation(summary = "댓글 삭제", description = "자신의 댓글을 삭제합니다.") + public ResponseEntity deletePost( + @Parameter(description = "삭제할 댓글 ID", required = true) @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails cs) { + commentService.deleteComment(cs.getUser().getId(), commentId); + return ResponseEntity.ok(null); + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/comment/dto/CommentRequest.java b/back/src/main/java/com/back/domain/comment/dto/CommentRequest.java new file mode 100644 index 0000000..985cb7c --- /dev/null +++ b/back/src/main/java/com/back/domain/comment/dto/CommentRequest.java @@ -0,0 +1,9 @@ +package com.back.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentRequest( + @NotBlank(message = "내용은 필수입니다") + String content, + Boolean hide) { +} diff --git a/back/src/main/java/com/back/domain/comment/dto/CommentResponse.java b/back/src/main/java/com/back/domain/comment/dto/CommentResponse.java new file mode 100644 index 0000000..6490267 --- /dev/null +++ b/back/src/main/java/com/back/domain/comment/dto/CommentResponse.java @@ -0,0 +1,31 @@ +package com.back.domain.comment.dto; + +import com.back.global.common.DateFormat; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "댓글 DTO") +public record CommentResponse( + @Schema(description = "댓글 ID", example = "1") + Long commentId, + + @Schema(description = "댓글 작성자 닉네임 또는 익명", example = "홍길동") + String author, + + @Schema(description = "댓글 내용", example = "좋은 글이네요!") + String content, + + @Schema(description = "댓글 좋아요 수", example = "10") + int likeCount, + + @Schema(description = "사용자가 해당 댓글의 작성자인지 여부", example = "true") + boolean isMine, + + @Schema(description = "사용자가 해당 댓글에 좋아요를 눌렀는지 여부", example = "true") + boolean isLiked, + + @Schema(description = "댓글 작성일자", example = "2025.09.23") + @DateFormat + LocalDateTime createdDate +) {} diff --git a/back/src/main/java/com/back/domain/comment/entity/Comment.java b/back/src/main/java/com/back/domain/comment/entity/Comment.java index 83c1e03..3313908 100644 --- a/back/src/main/java/com/back/domain/comment/entity/Comment.java +++ b/back/src/main/java/com/back/domain/comment/entity/Comment.java @@ -3,12 +3,10 @@ import com.back.domain.post.entity.Post; import com.back.domain.user.entity.User; import com.back.global.baseentity.BaseEntity; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import org.hibernate.annotations.ColumnDefault; import org.springframework.data.annotation.LastModifiedDate; @@ -24,9 +22,8 @@ @Entity @Table(name = "comments") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder public class Comment extends BaseEntity { @@ -56,4 +53,13 @@ public class Comment extends BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; + + public void checkUser(Long userId) { + if (!user.getId().equals(userId)) + throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + } + + public void updateContent(String content) { + this.content = content; + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/comment/enums/CommentSortType.java b/back/src/main/java/com/back/domain/comment/enums/CommentSortType.java new file mode 100644 index 0000000..1084e86 --- /dev/null +++ b/back/src/main/java/com/back/domain/comment/enums/CommentSortType.java @@ -0,0 +1,18 @@ +package com.back.domain.comment.enums; + +import lombok.Getter; + +/** + * 최신순, 좋아요순 + */ +@Getter +public enum CommentSortType { + LATEST("createdDate"), + LIKES("likeCount"); + + private final String property; + + CommentSortType(String property) { + this.property = property; + } +} diff --git a/back/src/main/java/com/back/domain/comment/mapper/CommentMappers.java b/back/src/main/java/com/back/domain/comment/mapper/CommentMappers.java new file mode 100644 index 0000000..8aaba1e --- /dev/null +++ b/back/src/main/java/com/back/domain/comment/mapper/CommentMappers.java @@ -0,0 +1,65 @@ +package com.back.domain.comment.mapper; + +import com.back.domain.comment.dto.CommentRequest; +import com.back.domain.comment.dto.CommentResponse; +import com.back.domain.comment.entity.Comment; +import com.back.domain.post.entity.Post; +import com.back.domain.user.entity.User; +import com.back.global.mapper.Mapper; +import com.back.global.mapper.MappingException; +import com.back.global.mapper.TwoWayMapper; + + +/** + * CommentMappers + * 댓글(Comment) 관련 엔티티 ↔ DTO 매핑 유틸리티 클래스. + * 구성: + * - COMMENT_READ : Comment → CommentResponse 변환 (읽기 전역 매퍼) + * - CommentCtxMapper : CommentRequest ↔ Comment ↔ CommentResponse 변환 (쓰기 매퍼, 컨텍스트 보유) + * - 사용자(User), 게시글(Post) 컨텍스트를 보유하여 엔티티 생성 시 활용 + * - 내가 쓴 댓글 여부, 좋아요 여부 등은 추후 구현 예정 + */ +public final class CommentMappers { + + private CommentMappers() {} + + public static final Mapper COMMENT_READ = e -> { + if (e == null) throw new MappingException("Comment is null"); + return new CommentResponse( + e.getId(), + e.isHide() ? "익명" : (e.getUser() != null ? e.getUser().getNickname() : null), + e.getContent(), + e.getLikeCount(), + false, // todo : 내가 쓴 댓글 여부 추후 구현 + false, // todo : 좋아요 여부 추후 구현 + e.getCreatedDate() + ); + }; + + public static final class CommentCtxMapper implements TwoWayMapper { + private final User user; + private final Post post; + + public CommentCtxMapper(User user, Post post) { + this.user = user; + this.post = post; + } + + @Override + public Comment toEntity(CommentRequest req) { + if (req == null) throw new MappingException("CommentRequest is null"); + return Comment.builder() + .user(user) + .post(post) + .content(req.content()) + .hide(req.hide() != null ? req.hide() : false) + .build(); + } + + @Override + public CommentResponse toResponse(Comment entity) { + return COMMENT_READ.map(entity); + } + } +} + diff --git a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java index 4ff32c9..c057d92 100644 --- a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java +++ b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java @@ -1,7 +1,11 @@ package com.back.domain.comment.repository; import com.back.domain.comment.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; /** @@ -9,4 +13,6 @@ */ @Repository public interface CommentRepository extends JpaRepository { + @Query("SELECT c FROM Comment c WHERE c.post.id = :postId") + Page findCommentsByPostId(@Param("postId") Long postId, Pageable pageable); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/comment/service/CommentService.java b/back/src/main/java/com/back/domain/comment/service/CommentService.java index d3ccb16..6af17f0 100644 --- a/back/src/main/java/com/back/domain/comment/service/CommentService.java +++ b/back/src/main/java/com/back/domain/comment/service/CommentService.java @@ -1,16 +1,69 @@ package com.back.domain.comment.service; +import com.back.domain.comment.dto.CommentRequest; +import com.back.domain.comment.dto.CommentResponse; +import com.back.domain.comment.entity.Comment; +import com.back.domain.comment.enums.CommentSortType; +import com.back.domain.comment.mapper.CommentMappers; import com.back.domain.comment.repository.CommentRepository; +import com.back.domain.post.entity.Post; +import com.back.domain.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * 댓글 관련 비즈니스 로직을 처리하는 서비스. */ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class CommentService { + private final UserRepository userRepository; + private final PostRepository postRepository; private final CommentRepository commentRepository; + public CommentResponse createComment(Long userId, Long postId, CommentRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + CommentMappers.CommentCtxMapper ctxMapper = new CommentMappers.CommentCtxMapper(user, post); + Comment savedComment = commentRepository.save(ctxMapper.toEntity(request)); + return ctxMapper.toResponse(savedComment); + } + + public Page getComments(Long userId, Long postId, Pageable pageable) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + Page commentsPage = commentRepository.findCommentsByPostId(postId, pageable); + return commentsPage.map(CommentMappers.COMMENT_READ::map); + } + + @Transactional + public Long updateComment(Long userId, Long commentId, CommentRequest request) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + comment.checkUser(userId); + comment.updateContent(request.content()); + return comment.getId(); + } + + public void deleteComment(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + comment.checkUser(userId); + commentRepository.delete(comment); + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/controller/LikeController.java b/back/src/main/java/com/back/domain/like/controller/LikeController.java index 55c5f73..9959d4d 100644 --- a/back/src/main/java/com/back/domain/like/controller/LikeController.java +++ b/back/src/main/java/com/back/domain/like/controller/LikeController.java @@ -1,16 +1,31 @@ package com.back.domain.like.controller; import com.back.domain.like.service.LikeService; +import com.back.global.security.CustomUserDetails; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; /** * 좋아요 관련 API 요청을 처리하는 컨트롤러. */ @RestController @RequiredArgsConstructor +@RequestMapping("/api/v1/posts") public class LikeController { - private final LikeService likeService; + @PostMapping("/{postId}/likes") + public ResponseEntity addLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { + likeService.addLike(cs.getUser().getId(), postId); + return ResponseEntity.status(HttpStatus.CREATED).body(null); + } + + @DeleteMapping("/{postId}/likes") + public ResponseEntity removeLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { + likeService.removeLike(postId, cs.getUser().getId()); + return ResponseEntity.ok(null); + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/entity/PostLike.java b/back/src/main/java/com/back/domain/like/entity/PostLike.java index fdab231..53b3cbc 100644 --- a/back/src/main/java/com/back/domain/like/entity/PostLike.java +++ b/back/src/main/java/com/back/domain/like/entity/PostLike.java @@ -4,11 +4,7 @@ import com.back.domain.user.entity.User; import com.back.global.baseentity.BaseEntity; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; /** * 게시글 좋아요 엔티티. @@ -24,9 +20,8 @@ } ) @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder public class PostLike extends BaseEntity { diff --git a/back/src/main/java/com/back/domain/like/repository/PostLikeRepository.java b/back/src/main/java/com/back/domain/like/repository/PostLikeRepository.java index 8a44f7e..c899027 100644 --- a/back/src/main/java/com/back/domain/like/repository/PostLikeRepository.java +++ b/back/src/main/java/com/back/domain/like/repository/PostLikeRepository.java @@ -2,11 +2,24 @@ import com.back.domain.like.entity.PostLike; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Set; + /** * 게시글 좋아요 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository. */ @Repository public interface PostLikeRepository extends JpaRepository { + @Modifying + @Query("DELETE FROM PostLike pl WHERE pl.post.id = :postId AND pl.user.id = :userId") + int deleteByPostIdAndUserId(@Param("postId") Long postId, @Param("userId") Long userId); + + boolean existsByPostIdAndUserId(Long postId, Long userId); + + @Query("SELECT pl.post.id FROM PostLike pl WHERE pl.user.id = :userId AND pl.post.id IN :postIds") + Set findLikedPostIdsByUserAndPostIds(@Param("userId") Long userId, @Param("postIds") Set postIds); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/service/LikeService.java b/back/src/main/java/com/back/domain/like/service/LikeService.java index d1c921d..0307883 100644 --- a/back/src/main/java/com/back/domain/like/service/LikeService.java +++ b/back/src/main/java/com/back/domain/like/service/LikeService.java @@ -1,18 +1,65 @@ package com.back.domain.like.service; +import com.back.domain.like.entity.PostLike; import com.back.domain.like.repository.CommentLikeRepository; import com.back.domain.like.repository.PostLikeRepository; +import com.back.domain.post.entity.Post; +import com.back.domain.post.repository.PostRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * 좋아요 관련 비즈니스 로직을 처리하는 서비스. */ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class LikeService { private final PostLikeRepository postLikeRepository; private final CommentLikeRepository commentLikeRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + @Transactional + public void addLike(Long userId, Long postId) { + Post post = postRepository.findByIdWithLock(postId) + .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + + if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + throw new ApiException(ErrorCode.POST_ALREADY_LIKED); + } + + PostLike postLike = createPostLike(post, userId); + + postLikeRepository.save(postLike); + post.incrementLikeCount(); + } + + @Transactional + public void removeLike(Long postId, Long userId) { + Post post = postRepository.findByIdWithLock(postId) + .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + + boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0; + + if (!deleted) { + throw new ApiException(ErrorCode.LIKE_NOT_FOUND); + } + + post.decrementLikeCount(); + } + + private PostLike createPostLike(Post post, Long userId) { + User userReference = userRepository.getReferenceById(userId); + return PostLike.builder() + .post(post) + .user(userReference) + .build(); + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/controller/PostController.java b/back/src/main/java/com/back/domain/post/controller/PostController.java index 3d8bbe1..8dabf7f 100644 --- a/back/src/main/java/com/back/domain/post/controller/PostController.java +++ b/back/src/main/java/com/back/domain/post/controller/PostController.java @@ -1,24 +1,28 @@ package com.back.domain.post.controller; +import com.back.domain.post.dto.PostDetailResponse; import com.back.domain.post.dto.PostRequest; -import com.back.domain.post.dto.PostResponse; import com.back.domain.post.dto.PostSearchCondition; +import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.service.PostService; -import com.back.global.common.ApiResponse; import com.back.global.common.PageResponse; +import com.back.global.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - /** * 게시글 관련 API 요청을 처리하는 컨트롤러. */ +@Tag(name = "Post", description = "게시글 관련 API") @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor @@ -28,39 +32,58 @@ public class PostController { // 게시글 생성 @PostMapping - public ApiResponse createPost( - @RequestBody @Valid PostRequest request) { - Long userId = 1L; // fixme 임시 사용자 ID - PostResponse response = postService.createPost(userId, request); - return ApiResponse.success(response, "성공적으로 생성되었습니다.", HttpStatus.OK); + @Operation(summary = "게시글 생성", description = "새 게시글을 생성합니다.") + public ResponseEntity createPost( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "생성할 게시글 정보", + required = true + ) + @RequestBody @Valid PostRequest request, + @AuthenticationPrincipal CustomUserDetails cs + ) { + PostDetailResponse response = postService.createPost(cs.getUser().getId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } // 게시글 목록 조회 @GetMapping - public ApiResponse> getPosts( - @ModelAttribute PostSearchCondition condition, Pageable pageable) { - Page responses = postService.getPosts(condition, pageable); - return ApiResponse.success(PageResponse.of(responses), "성공적으로 조회되었습니다.", HttpStatus.OK); + @Operation(summary = "게시글 목록 조회", description = "게시글 목록을 조회합니다.") + public ResponseEntity> getPosts( + @Parameter(description = "검색 조건") @ModelAttribute PostSearchCondition condition, + @Parameter(description = "페이지 정보") Pageable pageable, + @AuthenticationPrincipal CustomUserDetails cs) { + Page responses = postService.getPosts(cs.getUser().getId(), condition, pageable); + return ResponseEntity.ok(PageResponse.of(responses)); } // 게시글 단건 조회 @GetMapping("/{postId}") - public ApiResponse getPost(@PathVariable Long postId) { - return ApiResponse.success(postService.getPost(postId), "성공적으로 조회되었습니다.", HttpStatus.OK); + @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 게시글을 조회합니다.") + public ResponseEntity getPost( + @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails cs) { + return ResponseEntity.ok(postService.getPost(cs.getUser().getId(), postId)); } @PutMapping("/{postId}") - public ApiResponse updatePost( - @PathVariable Long postId, - @RequestBody @Valid PostRequest request) { - Long userId = 1L; // fixme 임시 사용자 ID - return ApiResponse.success(postService.updatePost(userId, postId, request), "성공적으로 수정되었습니다.", HttpStatus.OK); + @Operation(summary = "게시글 수정", description = "게시글 ID로 게시글을 수정합니다.") + public ResponseEntity updatePost( + @Parameter(description = "수정할 게시글 ID", required = true) @PathVariable Long postId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "수정할 게시글 정보", + required = true + ) + @RequestBody @Valid PostRequest request, + @AuthenticationPrincipal CustomUserDetails cs) { + return ResponseEntity.ok(postService.updatePost(cs.getUser().getId(), postId, request)); } @DeleteMapping("/{postId}") - public ApiResponse deletePost(@PathVariable Long postId) { - Long userId = 1L; // fixme 임시 사용자 ID - postService.deletePost(userId, postId); - return ApiResponse.success(null, "성공적으로 삭제되었습니다.", HttpStatus.OK); + @Operation(summary = "게시글 삭제", description = "게시글 ID로 게시글을 삭제합니다.") + public ResponseEntity deletePost( + @Parameter(description = "삭제할 게시글 ID", required = true) @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails cs) { + postService.deletePost(cs.getUser().getId(), postId); + return ResponseEntity.ok(null); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..c3a1658 --- /dev/null +++ b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java @@ -0,0 +1,37 @@ +package com.back.domain.post.dto; + +import com.back.domain.post.enums.PostCategory; +import com.back.global.common.DateFormat; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "게시글 상세 응답 DTO") +public record PostDetailResponse( + @Schema(description = "게시글 ID", example = "1") + Long postId, + + @Schema(description = "게시글 제목", example = "테스트 게시글") + String title, + + @Schema(description = "게시글 내용", example = "게시글 본문 내용") + String content, + + @Schema(description = "작성자 닉네임 또는 '익명'", example = "홍길동") + String author, + + @Schema(description = "게시글 카테고리") + PostCategory category, + + @Schema(description = "좋아요 개수", example = "10") + int likeCount, + + @Schema(description = "현재 로그인 사용자의 좋아요 여부", example = "true") + boolean liked, + + @Schema(description = "게시글 작성일자", example = "2025.09.23") + @DateFormat + LocalDateTime createdDate +) {} + diff --git a/back/src/main/java/com/back/domain/post/dto/PostRequest.java b/back/src/main/java/com/back/domain/post/dto/PostRequest.java index badd03e..1612f9b 100644 --- a/back/src/main/java/com/back/domain/post/dto/PostRequest.java +++ b/back/src/main/java/com/back/domain/post/dto/PostRequest.java @@ -14,5 +14,7 @@ public record PostRequest( String content, @NotNull(message = "카테고리는 필수입니다") - PostCategory category + PostCategory category, + + Boolean hide ) { } diff --git a/back/src/main/java/com/back/domain/post/dto/PostResponse.java b/back/src/main/java/com/back/domain/post/dto/PostResponse.java deleted file mode 100644 index 4e367e8..0000000 --- a/back/src/main/java/com/back/domain/post/dto/PostResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.back.domain.post.dto; - -import com.back.domain.post.enums.PostCategory; - -import java.time.LocalDateTime; - -/** - * @param id - * @param title - * @param content - * @param category - * @param hide - * @param likeCount - * @param createdDate - * fixme @param createdBy 추가 예정 - */ -public record PostResponse( - Long id, - String title, - String content, - String author, - PostCategory category, - boolean hide, - int likeCount, - LocalDateTime createdDate -) { } diff --git a/back/src/main/java/com/back/domain/post/dto/PostSummaryResponse.java b/back/src/main/java/com/back/domain/post/dto/PostSummaryResponse.java new file mode 100644 index 0000000..aa485a2 --- /dev/null +++ b/back/src/main/java/com/back/domain/post/dto/PostSummaryResponse.java @@ -0,0 +1,39 @@ +package com.back.domain.post.dto; + + +import com.back.global.common.DateFormat; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +/** + * 게시글 목록 조회 시 불필요한 정보를 제외한 요약 응답 DTO + */ +@Schema(description = "게시글 요약 응답 DTO") +public record PostSummaryResponse( + @Schema(description = "게시글 ID", example = "1") + Long postId, + + @Schema(description = "게시글 제목", example = "테스트 게시글") + String title, + + @Schema(description = "게시판 타입", example = "잡담") + String boardType, + + @Schema(description = "작성자 닉네임 또는 익명", example = "홍길동") + String author, + + @Schema(description = "게시글 작성일자", example = "2025.09.23") + @DateFormat + LocalDateTime createdDate, + + @Schema(description = "댓글 개수", example = "5") + int commentCount, + + @Schema(description = "좋아요 개수", example = "10") + int likeCount, + + @Schema(description = "현재 로그인 사용자의 해당 게시글 좋아요 여부", example = "true") + boolean liked +) {} diff --git a/back/src/main/java/com/back/domain/post/entity/Post.java b/back/src/main/java/com/back/domain/post/entity/Post.java index b322cf9..fec3e03 100644 --- a/back/src/main/java/com/back/domain/post/entity/Post.java +++ b/back/src/main/java/com/back/domain/post/entity/Post.java @@ -1,5 +1,6 @@ package com.back.domain.post.entity; +import com.back.domain.comment.entity.Comment; import com.back.domain.post.enums.PostCategory; import com.back.domain.user.entity.User; import com.back.global.baseentity.BaseEntity; @@ -12,10 +13,13 @@ import org.springframework.data.annotation.LastModifiedDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; /** * 게시글 엔티티. * 사용자가 작성한 게시글의 정보를 저장합니다. + * 인덱스... */ @Entity @Getter @@ -52,9 +56,9 @@ public class Post extends BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; - public void assignUser(User user) { - this.user = user; - } + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List comments = new ArrayList<>(); public void updatePost(String title, String content, PostCategory category) { this.title = title; @@ -66,4 +70,14 @@ public void checkUser(User targetUser) { if (!user.equals(targetUser)) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/mapper/PostMapper.java b/back/src/main/java/com/back/domain/post/mapper/PostMapper.java deleted file mode 100644 index 99cd724..0000000 --- a/back/src/main/java/com/back/domain/post/mapper/PostMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.back.domain.post.mapper; - -import com.back.domain.post.dto.PostRequest; -import com.back.domain.post.dto.PostResponse; -import com.back.domain.post.entity.Post; - -import java.util.List; - -/** - * PostMapper - * 엔티티와 PostRequest, PostResponse 간의 변환을 담당하는 매퍼 클래스 - */ -public abstract class PostMapper { - public static Post toEntity(PostRequest request) { - return Post.builder() - .title(request.title()) - .content(request.content()) - .category(request.category()) - .hide(false) - .likeCount(0) - .build(); - } - - public static PostResponse toResponse(Post post) { - return new PostResponse( - post.getId(), - post.getTitle(), - post.getContent(), - post.getUser().getNickname(), - post.getCategory(), - post.isHide(), - post.getLikeCount(), - post.getCreatedDate() - ); - } - - public static List toResponseList(List posts) { - return posts.stream() - .map(PostMapper::toResponse) - .toList(); - } -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java new file mode 100644 index 0000000..d32d554 --- /dev/null +++ b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java @@ -0,0 +1,53 @@ +package com.back.domain.post.mapper; + +import com.back.domain.post.dto.PostDetailResponse; +import com.back.domain.post.dto.PostRequest; +import com.back.domain.post.dto.PostSummaryResponse; +import com.back.domain.post.entity.Post; +import com.back.domain.user.entity.User; + +import java.util.List; + +/** + * PostMapper + * 엔티티와 PostRequest, PostResponse 간의 변환을 담당하는 매퍼 클래스 + */ +public abstract class PostMappers { + public static Post toEntity(PostRequest request, User user) { + return Post.builder() + .title(request.title()) + .content(request.content()) + .category(request.category()) + .user(user) + .hide(false) + .likeCount(0) + .build(); + } + + public static PostDetailResponse toDetailResponse(Post post, Boolean isLiked) { + return new PostDetailResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.isHide() ? "익명" : post.getUser().getNickname(), + post.getCategory(), + post.getLikeCount(), + isLiked, + post.getCreatedDate() + ); + } + + public static PostSummaryResponse toSummaryResponse(Post post, Boolean isLiked) { + return new PostSummaryResponse( + post.getId(), + post.getTitle(), + post.getCategory().name(), + post.isHide() ? "익명" : post.getUser().getNickname(), + post.getCreatedDate(), + post.getComments().size(), + post.getLikeCount(), + isLiked + ); + } + +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/repository/PostRepository.java b/back/src/main/java/com/back/domain/post/repository/PostRepository.java index fa6dd72..9645019 100644 --- a/back/src/main/java/com/back/domain/post/repository/PostRepository.java +++ b/back/src/main/java/com/back/domain/post/repository/PostRepository.java @@ -1,12 +1,21 @@ package com.back.domain.post.repository; import com.back.domain.post.entity.Post; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + /** * 게시글 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository. */ @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Post p WHERE p.id = :postId") + Optional findByIdWithLock(@Param("postId") Long postId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index 01ce950..0a14531 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -1,10 +1,12 @@ package com.back.domain.post.service; +import com.back.domain.like.repository.PostLikeRepository; import com.back.domain.post.dto.PostRequest; -import com.back.domain.post.dto.PostResponse; +import com.back.domain.post.dto.PostDetailResponse; import com.back.domain.post.dto.PostSearchCondition; +import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.entity.Post; -import com.back.domain.post.mapper.PostMapper; +import com.back.domain.post.mapper.PostMappers; import com.back.domain.post.repository.PostRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; @@ -16,8 +18,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.util.List; -import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** * 게시글 관련 비즈니스 로직을 처리하는 서비스. @@ -29,42 +33,54 @@ public class PostService { private final UserRepository userRepository; private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; @Transactional - public PostResponse createPost(Long userId, PostRequest request) { + public PostDetailResponse createPost(Long userId, PostRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - Post post = PostMapper.toEntity(request); - post.assignUser(user); + Post post = PostMappers.toEntity(request, user); Post savedPost = postRepository.save(post); - return PostMapper.toResponse(savedPost); + return PostMappers.toDetailResponse(savedPost, false); } - public PostResponse getPost(Long postId) { - return postRepository.findById(postId) - .filter(post -> !post.isHide()) - .map(PostMapper::toResponse) + public PostDetailResponse getPost(Long userId, Long postId) { + Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + boolean isLiked = postLikeRepository.existsByPostIdAndUserId(postId, userId); + return PostMappers.toDetailResponse(post, isLiked); } - public Page getPosts(PostSearchCondition condition, Pageable pageable) { - return postRepository.searchPosts(condition, pageable) - .map(PostMapper::toResponse); + /** + * 게시글 목록을 조회하고 사용자의 좋아요 상태를 포함한 응답을 반환. + */ + public Page getPosts(Long userId, PostSearchCondition condition, Pageable pageable) { + Page posts = postRepository.searchPosts(condition, pageable); + + Set likedPostIds = getUserLikedPostIds(userId, posts); + + return posts.map(post -> PostMappers.toSummaryResponse( + post, + likedPostIds.contains(post.getId()) + )); } @Transactional - public PostResponse updatePost(Long userId, Long postId, PostRequest request) { - Post post = validatePostOwnership(userId, postId); + public Long updatePost(Long userId, Long postId, PostRequest request) { + if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + Post post = validatePostOwnership(userId, postId); post.updatePost(request.title(), request.content(), request.category()); - return PostMapper.toResponse(post); + return postId; } @Transactional public void deletePost(Long userId, Long postId) { + if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + Post post = validatePostOwnership(userId, postId); postRepository.delete(post); } @@ -80,4 +96,13 @@ private Post validatePostOwnership(Long userId, Long postId) { return post; } + + private Set getUserLikedPostIds(Long userId, Page posts) { + Set postIds = posts.getContent() + .stream() + .map(Post::getId) + .collect(Collectors.toSet()); + + return postLikeRepository.findLikedPostIdsByUserAndPostIds(userId, postIds); + } } \ No newline at end of file diff --git a/back/src/main/java/com/back/global/common/DateFormat.java b/back/src/main/java/com/back/global/common/DateFormat.java new file mode 100644 index 0000000..66cd7cc --- /dev/null +++ b/back/src/main/java/com/back/global/common/DateFormat.java @@ -0,0 +1,16 @@ +package com.back.global.common; + +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") +public @interface DateFormat { +} diff --git a/back/src/main/java/com/back/global/exception/ErrorCode.java b/back/src/main/java/com/back/global/exception/ErrorCode.java index abf658f..8937101 100644 --- a/back/src/main/java/com/back/global/exception/ErrorCode.java +++ b/back/src/main/java/com/back/global/exception/ErrorCode.java @@ -30,6 +30,7 @@ public enum ErrorCode { // Post Errors POST_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "Post Not Found"), + POST_ALREADY_LIKED(HttpStatus.BAD_REQUEST, "P002", "Post Already Liked"), // Comment Errors COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CM001", "Comment Not Found"), diff --git a/back/src/main/resources/application-dev.yml b/back/src/main/resources/application-dev.yml index 31dea49..1cf1656 100644 --- a/back/src/main/resources/application-dev.yml +++ b/back/src/main/resources/application-dev.yml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:h2:./db_dev;MODE=MySQL + url: jdbc:h2:./db_dev;MODE=PostgreSQL; username: sa password: driver-class-name: org.h2.Driver \ No newline at end of file diff --git a/back/src/main/resources/application-test.yml b/back/src/main/resources/application-test.yml index 4603eaf..8855b1b 100644 --- a/back/src/main/resources/application-test.yml +++ b/back/src/main/resources/application-test.yml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:h2:mem:db_test;MODE=MySQL + url: jdbc:h2:mem:db_test;MODE=PostgreSQL; username: sa password: driver-class-name: org.h2.Driver \ No newline at end of file diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index d5fd910..2b08249 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -18,6 +18,7 @@ spring: use_sql_comments: true format_sql: true highlight_sql: true + default_batch_fetch_size: 100 security: # 여기에 추가 oauth2: client: diff --git a/back/src/test/java/com/back/domain/comment/controller/CommentControllerTest.java b/back/src/test/java/com/back/domain/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..d499dc5 --- /dev/null +++ b/back/src/test/java/com/back/domain/comment/controller/CommentControllerTest.java @@ -0,0 +1,291 @@ +package com.back.domain.comment.controller; + +import com.back.domain.comment.dto.CommentRequest; +import com.back.domain.comment.entity.Comment; +import com.back.domain.comment.repository.CommentRepository; +import com.back.domain.post.entity.Post; +import com.back.domain.post.repository.PostRepository; +import com.back.domain.user.entity.Gender; +import com.back.domain.user.entity.Mbti; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.security.CustomUserDetails; +import com.fasterxml.jackson.databind.ObjectMapper; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ReflectionUtils; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.UUID; + +import static java.time.LocalDateTime.now; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class CommentControllerTest { + + @Autowired private UserRepository userRepository; + @Autowired private PostRepository postRepository; + @Autowired private CommentRepository commentRepository; + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + private User testUser; + private User anotherUser; + private Post testPost; + private Comment testComment; + + @BeforeEach + void setUp() { + String uid1 = UUID.randomUUID().toString().substring(0, 5); + String uid2 = UUID.randomUUID().toString().substring(0, 5); + + testUser = userRepository.save(User.builder() + .email("testuser" + uid1 + "@example.com") + .nickname("nickname" + uid1) + .username("tester" + uid1) + .password("password") + .gender(Gender.M) + .role(Role.USER) + .mbti(Mbti.ISFJ) + .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) + .build()); + + anotherUser = userRepository.save(User.builder() + .email("another" + uid2 + "@example.com") + .nickname("anotherNick" + uid2) + .username("another" + uid2) + .password("password") + .gender(Gender.F) + .role(Role.USER) + .mbti(Mbti.ENTP) + .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) + .build()); + + // 테스트 게시글 생성 + testPost = postRepository.save(Post.builder() + .title("테스트 게시글") + .content("테스트 내용") + .user(testUser) + .build()); + + // 인증 사용자 세팅 + CustomUserDetails cs = new CustomUserDetails(testUser); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(cs, null, cs.getAuthorities()) + ); + } + + @Nested + @DisplayName("댓글 생성") + class CreateComment { + + @Test + @DisplayName("성공 - 정상 요청") + void success() throws Exception { + CommentRequest request = new CommentRequest("테스트 댓글", true); + + mockMvc.perform(post("/api/v1/posts/{postId}/comments", testPost.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toJson(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("테스트 댓글")); + } + } + + @Nested + @DisplayName("댓글 목록 조회") + class GetComments { + + @Test + @DisplayName("성공 - 기본 파라미터로 댓글 목록 조회") + void success() throws Exception { + createComment("테스트 댓글 1", testUser, testPost, 0, null); + createComment("테스트 댓글 2", testUser, testPost, 0, null); + createComment("테스트 댓글 3", testUser, testPost, 0, null); + + mockMvc.perform(get("/api/v1/posts/{postId}/comments", testPost.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items.length()").value(3)) + .andExpect(jsonPath("$.totalElements").value(3)) + .andExpect(jsonPath("$.totalPages").value(1)); + } + + @Test + @DisplayName("성공 - LATEST 정렬로 댓글 목록 조회") + void successWithLatestSort() throws Exception { + createComment("오래된 댓글", testUser, testPost, 0, now().minusDays(2)); + createComment("중간 댓글", testUser, testPost, 0, now().minusDays(1)); + createComment("가장 최근 댓글", testUser, testPost, 0, now()); + + mockMvc.perform(get("/api/v1/posts/{postId}/comments", testPost.getId()) + .param("sortType", "LATEST") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].content").value("가장 최근 댓글")) + .andExpect(jsonPath("$.items[1].content").value("중간 댓글")) + .andExpect(jsonPath("$.items[2].content").value("오래된 댓글")); + } + + @Test + @DisplayName("성공 - LIKES 정렬로 댓글 목록 조회") + void successWithLikesSort() throws Exception { + createComment("좋아요 2개", testUser, testPost, 2, null); + createComment("좋아요 5개", testUser, testPost, 5, null); + createComment("좋아요 10개", testUser, testPost, 10, null); + + mockMvc.perform(get("/api/v1/posts/{postId}/comments", testPost.getId()) + .param("sortType", "LIKES") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].likeCount").value(10)) + .andExpect(jsonPath("$.items[1].likeCount").value(5)) + .andExpect(jsonPath("$.items[2].likeCount").value(2)); + } + + @Test + @DisplayName("성공 - 빈 댓글 목록 조회") + void successWithEmptyComments() throws Exception { + mockMvc.perform(get("/api/v1/posts/{postId}/comments", testPost.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items.length()").value(0)) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.totalPages").value(0)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 게시글 ID") + void failWithNonExistentPostId() throws Exception { + mockMvc.perform(get("/api/v1/posts/{postId}/comments", 999L) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("댓글 수정") + class UpdateCommentTest { + + @Test + @DisplayName("성공 - 유효한 요청으로 댓글 수정") + void updateComment_Success() throws Exception { + testComment = createComment("테스트 댓글", testUser, testPost, 0, null); + CommentRequest request = new CommentRequest("수정된 댓글입니다.", false); + + mockMvc.perform(put("/api/v1/posts/{postId}/comments/{commentId}", + testPost.getId(), + testComment.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toJson(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").value(testComment.getId())); + + Comment updatedComment = commentRepository.findById(testComment.getId()).orElseThrow(); + assertThat(updatedComment.getContent()).isEqualTo(request.content()); + assertThat(updatedComment.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("실패 - 빈 내용으로 댓글 수정 시도") + void updateComment_EmptyContent_Fail() throws Exception { + testComment = createComment("테스트 댓글", testUser, testPost, 0, null); + CommentRequest request = new CommentRequest("", false); + + mockMvc.perform(put("/api/v1/posts/{postId}/comments/{commentId}", + testPost.getId(), + testComment.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(toJson(request))) + .andExpect(status().isBadRequest()); + + Comment unchangedComment = commentRepository.findById(testComment.getId()).orElseThrow(); + assertThat(unchangedComment.getContent()).isEqualTo("테스트 댓글"); + } + } + + @Nested + @DisplayName("댓글 삭제") + class DeleteCommentTest { + + @Test + @DisplayName("성공 - 유효한 요청으로 댓글 삭제") + void deleteComment_Success() throws Exception { + testComment = createComment("테스트 댓글", testUser, testPost, 0, now()); + + mockMvc.perform(delete("/api/v1/posts/{postId}/comments/{commentId}", + testPost.getId(), + testComment.getId())) + .andExpect(status().isOk()); + + assertThat(commentRepository.findById(testComment.getId())).isEmpty(); + } + + @Test + @DisplayName("실패 - 다른 사용자의 댓글 삭제 시도") + void deleteComment_Unauthorized_Fail() throws Exception { + testComment = createComment("테스트 댓글", anotherUser, testPost, 0, null); + + mockMvc.perform(delete("/api/v1/posts/{postId}/comments/{commentId}", + testPost.getId(), + testComment.getId())) + .andExpect(status().isUnauthorized()); + + assertThat(commentRepository.findById(testComment.getId())).isPresent(); + } + + @Test + @DisplayName("실패 - 존재하지 않는 댓글 삭제 시도") + void deleteComment_CommentNotFound_Fail() throws Exception { + testComment = createComment("테스트 댓글", testUser, testPost, 0, null); + + mockMvc.perform(delete("/api/v1/posts/{postId}/comments/{commentId}", + testPost.getId(), + 999L)) + .andExpect(status().isNotFound()); + } + } + + private Comment createComment(String content, User user, Post post, int likeCount, LocalDateTime createdDate) { + Comment comment = Comment.builder() + .content(content) + .user(user) + .post(post) + .hide(false) + .likeCount(likeCount) + .build(); + if (createdDate != null) { + Field createdDateField = ReflectionUtils.findField(Comment.class, "createdDate"); + ReflectionUtils.makeAccessible(createdDateField); + ReflectionUtils.setField(createdDateField, comment, createdDate); + } + return commentRepository.save(comment); + } + + private String toJson(Object obj) throws Exception { + return objectMapper.writeValueAsString(obj); + } +} + diff --git a/back/src/test/java/com/back/domain/like/controller/LikeControllerTest.java b/back/src/test/java/com/back/domain/like/controller/LikeControllerTest.java new file mode 100644 index 0000000..f6c883a --- /dev/null +++ b/back/src/test/java/com/back/domain/like/controller/LikeControllerTest.java @@ -0,0 +1,184 @@ +package com.back.domain.like.controller; + +import com.back.domain.like.entity.PostLike; +import com.back.domain.like.repository.PostLikeRepository; +import com.back.domain.post.entity.Post; +import com.back.domain.post.repository.PostRepository; +import com.back.domain.user.entity.Gender; +import com.back.domain.user.entity.Mbti; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ErrorCode; +import com.back.global.security.CustomUserDetails; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) +@Sql( + statements = { + "SET REFERENTIAL_INTEGRITY FALSE", + "TRUNCATE TABLE POST_LIKES", + "TRUNCATE TABLE POST", + "TRUNCATE TABLE USERS", + "ALTER TABLE POST_LIKES ALTER COLUMN ID RESTART WITH 1", + "ALTER TABLE POST ALTER COLUMN ID RESTART WITH 1", + "ALTER TABLE USERS ALTER COLUMN ID RESTART WITH 1", + "SET REFERENTIAL_INTEGRITY TRUE" + }, + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD +) +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class LikeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostLikeRepository postLikeRepository; + + static final int THREAD_POOL_SIZE = 50; + static final int CONCURRENT_USERS = 50; + + private Post testPost; + private User testUser; + + @BeforeEach + void setUp() { + testUser = createTestUser("testuser"); + testPost = createTestPost(testUser); + + setAuthentication(testUser); + } + + @Nested + @DisplayName("좋아요 등록 및 취소") + class LikeFeatureTest { + + @Test + @DisplayName("성공 - 50명 동시 좋아요 등록") + void addLikeConcurrent() throws InterruptedException { + List testUsers = IntStream.rangeClosed(1, CONCURRENT_USERS) + .mapToObj(i -> createTestUser("concurrent" + i)) + .collect(Collectors.toList()); + + userRepository.saveAll(testUsers); + userRepository.flush(); + + try (ExecutorService es = Executors.newFixedThreadPool(THREAD_POOL_SIZE)) { + List> tasks = testUsers.stream() + .map(user -> (Callable) () -> { + performLike(user, testPost.getId()); + return null; + }) + .toList(); + + es.invokeAll(tasks); + } + + Post post = postRepository.findById(testPost.getId()).orElseThrow(); + assertEquals(CONCURRENT_USERS, post.getLikeCount()); + } + + @Test + @DisplayName("실패 - 이미 좋아요를 누른 유저가 다시 좋아요 등록 시 예외") + void addLikeAlreadyLiked() throws Exception { + postLikeRepository.save(PostLike.builder().user(testUser).post(testPost).build()); + postLikeRepository.flush(); + + mockMvc.perform(post("/api/v1/posts/{postId}/likes", testPost.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.POST_ALREADY_LIKED.getCode())) + .andExpect(jsonPath("$.message").value(ErrorCode.POST_ALREADY_LIKED.getMessage())); + } + + @Test + @DisplayName("성공 - 좋아요 취소") + void removeLike() throws Exception { + postLikeRepository.save(PostLike.builder().user(testUser).post(testPost).build()); + testPost.incrementLikeCount(); + postRepository.save(testPost); + postRepository.flush(); + + mockMvc.perform(delete("/api/v1/posts/{postId}/likes", testPost.getId())) + .andExpect(status().isOk()); + + Post post = postRepository.findById(testPost.getId()).orElseThrow(); + assertEquals(0, post.getLikeCount()); + } + } + + /** + * 테스트에서 사용할 공통 메서드 + */ + private User createTestUser(String prefix) { + String uuid = UUID.randomUUID().toString().substring(0, 5); + return userRepository.save(User.builder() + .email(prefix + uuid + "@example.com") + .password("password") + .username(prefix) + .nickname(prefix + "_" + uuid) + .gender(Gender.M) + .role(Role.USER) + .mbti(Mbti.ISFJ) + .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) + .beliefs("도전") + .build()); + } + + private Post createTestPost(User user) { + return postRepository.save(Post.builder() + .title("테스트 게시글") + .content("테스트 내용") + .user(user) + .build()); + } + + private void setAuthentication(User user) { + CustomUserDetails cs = new CustomUserDetails(user); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(cs, null, cs.getAuthorities()) + ); + } + + private void performLike(User user, Long postId) throws Exception { + setAuthentication(user); + mockMvc.perform(post("/api/v1/posts/{postId}/likes", postId)) + .andExpect(status().isOk()); + SecurityContextHolder.clearContext(); + } +} diff --git a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java index 0f6d6e2..6e68e47 100644 --- a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java +++ b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java @@ -3,12 +3,14 @@ import com.back.domain.post.dto.PostRequest; import com.back.domain.post.entity.Post; import com.back.domain.post.enums.PostCategory; -import com.back.domain.post.enums.SearchType; -import com.back.domain.post.fixture.PostFixture; import com.back.domain.post.repository.PostRepository; +import com.back.domain.user.entity.Gender; +import com.back.domain.user.entity.Mbti; +import com.back.domain.user.entity.Role; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ErrorCode; +import com.back.global.security.CustomUserDetails; import com.fasterxml.jackson.databind.ObjectMapper; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; @@ -20,13 +22,16 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.UUID; + import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -48,16 +53,42 @@ class PostControllerTest { @Autowired private UserRepository userRepository; - private PostFixture fixture; private User testUser; private User anotherUser; @BeforeEach void setUp() { - fixture = new PostFixture(userRepository, postRepository); - testUser = fixture.createTestUser(); - anotherUser = fixture.createAnotherUser(); - fixture.createPostsForPaging(testUser, 5); + String uid1 = UUID.randomUUID().toString().substring(0, 5); + String uid2 = UUID.randomUUID().toString().substring(0, 5); + + testUser = userRepository.save(User.builder() + .email("testuser" + uid1 + "@example.com") + .nickname("nickname" + uid1) + .username("tester" + uid1) + .password("password") + .gender(Gender.M) + .role(Role.USER) + .mbti(Mbti.ISFJ) + .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) + .build()); + + anotherUser = userRepository.save(User.builder() + .email("another" + uid2 + "@example.com") + .nickname("anotherNick" + uid2) + .username("another" + uid2) + .password("password") + .gender(Gender.F) + .role(Role.USER) + .mbti(Mbti.ENTP) + .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) + .build()); + + userRepository.save(anotherUser); + + CustomUserDetails cs = new CustomUserDetails(testUser); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(cs, null, cs.getAuthorities()) + ); } @Nested @@ -67,198 +98,168 @@ class CreatePost { @Test @DisplayName("성공 - 정상 요청") void success() throws Exception { - // given - PostRequest request = fixture.createPostRequest(); + PostRequest request = new PostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT, false); - // when & then - mockMvc.perform(post(PostFixture.API_BASE_PATH) + mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("테스트 게시글")) - .andExpect(jsonPath("$.data.content").value("테스트 내용입니다.")) - .andExpect(jsonPath("$.data.category").value("CHAT")) - .andExpect(jsonPath("$.message").value("성공적으로 생성되었습니다.")) - .andExpect(jsonPath("$.status").value(200)); + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.title").value("테스트 게시글")) + .andExpect(jsonPath("$.content").value("테스트 내용입니다.")) + .andExpect(jsonPath("$.category").value("CHAT")); } @Test @DisplayName("실패 - 유효성 검사 실패 (빈 제목)") - void fail_ValidationError_EmptyTitle() throws Exception { - // given - PostRequest request = fixture.createEmptyTitleRequest(); + void failEmptyTitle() throws Exception { + PostRequest request = new PostRequest("", "내용", PostCategory.SCENARIO, false); - // when & then - mockMvc.perform(post(PostFixture.API_BASE_PATH) + mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT_VALUE.getCode())) .andExpect(jsonPath("$.message").exists()); } - - @Test - @DisplayName("실패 - 유효성 검사 실패 (빈 내용)") - void fail_ValidationError_EmptyContent() throws Exception { - // given - PostRequest request = fixture.createEmptyContentRequest(); - - // when & then - mockMvc.perform(post(PostFixture.API_BASE_PATH) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT_VALUE.getCode())); - } } @Nested @DisplayName("게시글 조회") class GetPost { + private Post savedPost; + + @BeforeEach + void createPost() { + savedPost = Post.builder() + .title("조회 테스트 게시글") + .content("조회 테스트 내용입니다.") + .category(PostCategory.CHAT) + .user(testUser) + .build(); + postRepository.save(savedPost); + } @Test @DisplayName("성공 - 존재하는 게시글 조회") void success() throws Exception { - // given - Post savedPost = fixture.createPostForDetail(testUser); - - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH + "/{postId}", savedPost.getId())) + mockMvc.perform(get("/api/v1/posts/{postId}", savedPost.getId())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("조회 테스트 게시글")) - .andExpect(jsonPath("$.data.content").value("조회 테스트 내용입니다.")) - .andExpect(jsonPath("$.data.category").value("CHAT")) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.message").value("성공적으로 조회되었습니다.")); + .andExpect(jsonPath("$.title").value("조회 테스트 게시글")) + .andExpect(jsonPath("$.content").value("조회 테스트 내용입니다.")) + .andExpect(jsonPath("$.category").value("CHAT")); } @Test @DisplayName("실패 - 존재하지 않는 게시글 ID") - void fail_NotFound() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH + "/{postId}", PostFixture.NON_EXISTENT_POST_ID)) + void failNotFound() throws Exception { + mockMvc.perform(get("/api/v1/posts/{postId}", 999L)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(HttpStatus.NOT_FOUND.value())) .andExpect(jsonPath("$.code").value(ErrorCode.POST_NOT_FOUND.getCode())) .andExpect(jsonPath("$.message").value(ErrorCode.POST_NOT_FOUND.getMessage())) - .andExpect(jsonPath("$.path").value(PostFixture.API_BASE_PATH + "/" + PostFixture.NON_EXISTENT_POST_ID)); + .andExpect(jsonPath("$.path").value("/api/v1/posts/999")); } } @Nested @DisplayName("게시글 목록 조회") class GetPosts { - - @Test - @DisplayName("성공 - 페이징 파라미터가 없는 경우") - void successWithDefaultParameters() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.page").value(1)) - .andExpect(jsonPath("$.data.size").value(5)) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.message").value("성공적으로 조회되었습니다.")); + @BeforeEach + void createPosts() { + for (int i = 1; i <= 5; i++) { + Post post = Post.builder() + .title("게시글 " + i) + .content("내용 " + i) + .category(i % 2 == 0 ? PostCategory.SCENARIO : PostCategory.CHAT) + .user(testUser) + .build(); + postRepository.save(post); + } } @Test - @DisplayName("성공 - page와 size 모두 지정") - void successWithBothParameters() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH) - .param("page", "1") - .param("size", "5")) + @DisplayName("성공 - 기본 페이징") + void successWithDefaultParameters() throws Exception { + mockMvc.perform(get("/api/v1/posts")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items.length()").value(5)) - .andExpect(jsonPath("$.data.items[0].title").value("시나리오 게시글 2")) - .andExpect(jsonPath("$.data.items[1].title").value("시나리오 게시글 1")) - .andExpect(jsonPath("$.data.items[2].title").value("목록 게시글 5")) - .andExpect(jsonPath("$.data.page").value(1)) - .andExpect(jsonPath("$.data.size").value(5)) - .andExpect(jsonPath("$.data.totalElements").value(7)) - .andExpect(jsonPath("$.data.totalPages").value(2)) - .andExpect(jsonPath("$.data.last").value(false)); + .andExpect(jsonPath("$.page").value(1)) + .andExpect(jsonPath("$.size").value(5)); } @Test - @DisplayName("성공 - 카테고리 필터링 적용") + @DisplayName("성공 - 카테고리 필터링") void successWithCategoryFilter() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH) + mockMvc.perform(get("/api/v1/posts") .param("category", PostCategory.SCENARIO.name())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items[*].category").value( - Matchers.everyItem(Matchers.equalTo("SCENARIO")) - )) - .andExpect(jsonPath("$.data.items.length()").value(2)) - .andDo(print()); + .andExpect(jsonPath("$.items[*].category", + Matchers.everyItem(Matchers.equalTo("SCENARIO")))) + .andExpect(jsonPath("$.items.length()").value(2)); } @Test @DisplayName("성공 - 제목 + 내용 검색") void successWithTitleContentSearch() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH) - .param("searchType", SearchType.TITLE_CONTENT.name()) - .param("keyword", "시나리오")) + mockMvc.perform(get("/api/v1/posts") + .param("searchType", "TITLE_CONTENT") + .param("keyword", "게시글")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items.length()").value(2)) - .andDo(print()); + .andExpect(jsonPath("$.items.length()").value(5)); } @Test @DisplayName("성공 - 작성자 검색") void successWithAuthorSearch() throws Exception { - // when & then - mockMvc.perform(get(PostFixture.API_BASE_PATH) - .param("searchType", SearchType.AUTHOR.name()) - .param("keyword", "작성자1")) + mockMvc.perform(get("/api/v1/posts") + .param("searchType", "AUTHOR") + .param("keyword", "테스트유저")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items[*].author", - Matchers.everyItem(Matchers.containsStringIgnoringCase("작성자1")))) - .andDo(print()); + .andExpect(jsonPath("$.items[*].author", + Matchers.everyItem(Matchers.containsStringIgnoringCase("테스트유저")))); } } @Nested @DisplayName("게시글 수정") class UpdatePost { + private Post savedPost; + @BeforeEach + void createPost() { + savedPost = Post.builder() + .title("수정 테스트 게시글") + .content("수정 전 내용") + .category(PostCategory.CHAT) + .user(testUser) + .build(); + postRepository.save(savedPost); + } @Test @DisplayName("성공 - 본인 게시글 수정") - @Sql(statements = { - "UPDATE users SET id = 1 WHERE email = 'testUser@example.com'" - }) void success() throws Exception { - // given - User user1 = userRepository.findById(1L).orElseThrow(); - Post savedPost = fixture.createPostForUpdate(user1); - PostRequest updateRequest = fixture.createUpdateRequest(); + PostRequest updateRequest = new PostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT, false); - // when & then - mockMvc.perform(put(PostFixture.API_BASE_PATH + "/{postId}", savedPost.getId()) + mockMvc.perform(put("/api/v1/posts/{postId}", savedPost.getId()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("수정된 제목")) - .andExpect(jsonPath("$.data.content").value("수정된 내용")) - .andExpect(jsonPath("$.data.category").value("CHAT")); + .andExpect(jsonPath("$").value(savedPost.getId())); } @Test @DisplayName("실패 - 다른 사용자 게시글 수정") - @Sql(statements = { - "UPDATE users SET id = 1 WHERE email = 'testUser@example.com'", - "UPDATE users SET id = 2 WHERE email = 'anothertestUser@example.com'" - }) - void fail_UnauthorizedUser() throws Exception { - // given - User user2 = userRepository.findById(2L).orElseThrow(); - Post savedPost = fixture.createPostForUpdate(user2); - PostRequest updateRequest = fixture.createUpdateRequest(); - - // when & then - mockMvc.perform(put(PostFixture.API_BASE_PATH + "/{postId}", savedPost.getId()) + void failUnauthorizedUser() throws Exception { + Post otherPost = Post.builder() + .title("다른 사용자 게시글") + .content("내용") + .category(PostCategory.CHAT) + .user(anotherUser) + .build(); + postRepository.save(otherPost); + + PostRequest updateRequest = new PostRequest("수정 시도", "수정 시도 내용", PostCategory.CHAT, false); + + mockMvc.perform(put("/api/v1/posts/{postId}", otherPost.getId()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isUnauthorized()) diff --git a/back/src/test/java/com/back/domain/post/fixture/PostFixture.java b/back/src/test/java/com/back/domain/post/fixture/PostFixture.java deleted file mode 100644 index 559574b..0000000 --- a/back/src/test/java/com/back/domain/post/fixture/PostFixture.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.back.domain.post.fixture; - - -import com.back.domain.post.dto.PostRequest; -import com.back.domain.post.entity.Post; -import com.back.domain.post.enums.PostCategory; -import com.back.domain.post.repository.PostRepository; -import com.back.domain.user.entity.Gender; -import com.back.domain.user.entity.Mbti; -import com.back.domain.user.entity.Role; -import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 테스트에 필요한 데이터 관리 클래스 - */ -public class PostFixture { - - public static final String API_BASE_PATH = "/api/v1/posts"; - public static final Long NON_EXISTENT_POST_ID = 9999L; - - private final UserRepository userRepository; - private final PostRepository postRepository; - - public PostFixture(UserRepository userRepository, PostRepository postRepository) { - this.userRepository = userRepository; - this.postRepository = postRepository; - } - - // User 생성 - public User createTestUser() { - return createUser("testLoginId", "test@example.com", "testPassword", "작성자1", "닉네임1", Gender.M); - } - - public User createAnotherUser() { - return createUser("anotherLoginId", "another@example.com", "another", "작성자2", "닉네임2", Gender.F); - } - - private User createUser(String loginId, String email, String password, String username, String nickname, Gender gender) { - return userRepository.save(User.builder() - .email(email) - .password(password) - .username(username) - .nickname(nickname) - .beliefs("도전") - .gender(gender) - .role(Role.USER) - .mbti(Mbti.ISFJ) - .birthdayAt(LocalDateTime.of(2000, 1, 1, 0, 0)) - .build()); - } - - // Post 생성 - public Post createPost(User user, String title, String content, PostCategory category) { - return postRepository.save(Post.builder() - .title(title) - .content(content) - .category(category) - .user(user) - .build()); - } - - public List createPostsForPaging(User user, int count) { - List posts = new ArrayList<>(); - for (int i = 1; i <= count; i++) { - posts.add(createPost(user, "목록 게시글 " + i, "목록 내용 " + i, PostCategory.CHAT)); - } - for (int i = 1; i <= count / 2; i++) { - posts.add(createPost(user, "시나리오 게시글 " + i, "시나리오 내용 " + i, PostCategory.SCENARIO)); - } - return posts; - } - - public Post createPostForDetail(User user) { - return createPost(user, "조회 테스트 게시글", "조회 테스트 내용입니다.", PostCategory.CHAT); - } - - public Post createPostForUpdate(User user) { - return createPost(user, "수정 전 제목", "수정 전 내용", PostCategory.CHAT); - } - - // PostRequest 생성 - public PostRequest createPostRequest(String title, String content, PostCategory category) { - return new PostRequest(title, content, category); - } - - public PostRequest createPostRequest() { - return createPostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT); - } - - public PostRequest createEmptyTitleRequest() { - return createPostRequest("", "테스트 내용입니다.", PostCategory.CHAT); - } - - public PostRequest createEmptyContentRequest() { - return createPostRequest("테스트 게시글", "", PostCategory.CHAT); - } - - public PostRequest createUpdateRequest() { - return createPostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT); - } -} -