diff --git a/src/docs/asciidoc/board-api.adoc b/src/docs/asciidoc/board-api.adoc new file mode 100644 index 00000000..4258f842 --- /dev/null +++ b/src/docs/asciidoc/board-api.adoc @@ -0,0 +1,4 @@ +== 게시판 + +include::board/post-api.adoc[] +include::board/comment-api.adoc[] diff --git a/src/docs/asciidoc/board/comment-api.adoc b/src/docs/asciidoc/board/comment-api.adoc new file mode 100644 index 00000000..b4d5e195 --- /dev/null +++ b/src/docs/asciidoc/board/comment-api.adoc @@ -0,0 +1,109 @@ +ifndef::snippet[] +:snippet: ../../../../build/generated-snippets +endif::[] + +=== 댓글 페이징 조회 +:sectnums!: + +==== Request +include::{snippet}/comment/getCommentsByPaging/http-request.adoc[] + +===== Request header +include::{snippet}/comment/getCommentsByPaging/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/comment/getCommentsByPaging/path-parameters.adoc[] + +===== Query Parameters +include::{snippet}/comment/getCommentsByPaging/query-parameters.adoc[] + +==== Response +include::{snippet}/comment/getCommentsByPaging/http-response.adoc[] + +===== Response Body +include::{snippet}/comment/getCommentsByPaging/response-body.adoc[] +include::{snippet}/comment/getCommentsByPaging/response-fields.adoc[] + +:sectnums: + + +=== 댓글 단건 조회 +:sectnums!: + +==== Request +include::{snippet}/comment/getSingleComment/http-request.adoc[] + +===== Request header +include::{snippet}/comment/getSingleComment/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/comment/getSingleComment/path-parameters.adoc[] + +==== Response +include::{snippet}/comment/getSingleComment/http-response.adoc[] + +===== Response Body +include::{snippet}/comment/getSingleComment/response-body.adoc[] +include::{snippet}/comment/getSingleComment/response-fields.adoc[] + +:sectnums: + + +=== 댓글 생성 +:sectnums!: + +==== Request +include::{snippet}/comment/create/http-request.adoc[] + +===== Request header +include::{snippet}/comment/create/request-headers.adoc[] + +===== Request Fields +include::{snippet}/comment/create/request-fields.adoc[] + +===== Path Parameters +include::{snippet}/comment/create/path-parameters.adoc[] + +==== Response +include::{snippet}/comment/create/http-response.adoc[] + +:sectnums: + + +=== 댓글 수정 +:sectnums!: + +==== Request +include::{snippet}/comment/update/http-request.adoc[] + +===== Request header +include::{snippet}/comment/update/request-headers.adoc[] + +===== Request Fields +include::{snippet}/comment/update/request-fields.adoc[] + +===== Path Parameters +include::{snippet}/comment/update/path-parameters.adoc[] + +==== Response +include::{snippet}/comment/update/http-response.adoc[] + +:sectnums: + + +=== 댓글 삭제 +:sectnums!: + +==== Request +include::{snippet}/comment/delete/http-request.adoc[] + +===== Request header +include::{snippet}/comment/delete/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/comment/delete/path-parameters.adoc[] + +==== Response +include::{snippet}/comment/delete/http-response.adoc[] + +:sectnums: diff --git a/src/docs/asciidoc/board/post-api.adoc b/src/docs/asciidoc/board/post-api.adoc new file mode 100644 index 00000000..e63dc465 --- /dev/null +++ b/src/docs/asciidoc/board/post-api.adoc @@ -0,0 +1,139 @@ +ifndef::snippet[] +:snippet: ../../../../build/generated-snippets +endif::[] + +=== 게시글 페이징 조회 +:sectnums!: + +==== Request +include::{snippet}/post/getPostsByPaging/http-request.adoc[] + +===== Request header +include::{snippet}/post/getPostsByPaging/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/getPostsByPaging/path-parameters.adoc[] + +===== Query Parameters +include::{snippet}/post/getPostsByPaging/query-parameters.adoc[] + +==== Response +include::{snippet}/post/getPostsByPaging/http-response.adoc[] + +===== Response Body +include::{snippet}/post/getPostsByPaging/response-body.adoc[] +include::{snippet}/post/getPostsByPaging/response-fields.adoc[] + +:sectnums: + + +=== 게시글 단건 조회 +:sectnums!: + +==== Request +include::{snippet}/post/getSinglePost/http-request.adoc[] + +===== Request header +include::{snippet}/post/getSinglePost/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/getSinglePost/path-parameters.adoc[] + +==== Response +include::{snippet}/post/getSinglePost/http-response.adoc[] + +===== Response Body +include::{snippet}/post/getSinglePost/response-body.adoc[] +include::{snippet}/post/getSinglePost/response-fields.adoc[] + +:sectnums: + + +=== 게시글 등록 (파일 첨부) +:sectnums!: + +==== Request +include::{snippet}/post/createWithImage/http-request.adoc[] + +===== Request header +include::{snippet}/post/createWithImage/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/createWithImage/path-parameters.adoc[] + +===== Request Part +include::{snippet}/post/createWithImage/request-parts.adoc[] + +===== Request Part (postRequest) +include::{snippet}/post/createWithImage/request-part-postRequest-fields.adoc[] + +==== Response +include::{snippet}/post/createWithImage/http-response.adoc[] + +:sectnums: + + +=== 게시글 등록 (파일 미첨부) +:sectnums!: + +==== Request +include::{snippet}/post/createWithoutImage/http-request.adoc[] + +===== Request header +include::{snippet}/post/createWithoutImage/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/createWithoutImage/path-parameters.adoc[] + +===== Request Part +include::{snippet}/post/createWithoutImage/request-parts.adoc[] + +===== Request Part (postRequest) +include::{snippet}/post/createWithoutImage/request-part-postRequest-fields.adoc[] + +==== Response +include::{snippet}/post/createWithoutImage/http-response.adoc[] + +:sectnums: + + +=== 게시글 수정 +:sectnums!: + +==== Request +include::{snippet}/post/update/http-request.adoc[] + +===== Request header +include::{snippet}/post/update/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/update/path-parameters.adoc[] + +===== Request Part +include::{snippet}/post/update/request-parts.adoc[] + +===== Request Part (postRequest) +include::{snippet}/post/update/request-part-postRequest-fields.adoc[] + +==== Response +include::{snippet}/post/update/http-response.adoc[] + +:sectnums: + + +=== 게시글 삭제 +:sectnums!: + +==== Request +include::{snippet}/post/delete/http-request.adoc[] + +===== Request header +include::{snippet}/post/delete/request-headers.adoc[] + +===== Path Parameters +include::{snippet}/post/delete/path-parameters.adoc[] + +==== Response +include::{snippet}/post/delete/http-response.adoc[] + +:sectnums: diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index bb9927ff..5eba2bb7 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -24,6 +24,8 @@ include::event-api.adoc[] include::form-api.adoc[] +include::board-api.adoc[] + include::notification-api.adoc[] include::exception-api.adoc[] diff --git a/src/docs/asciidoc/user/bookmark-api.adoc b/src/docs/asciidoc/user/bookmark-api.adoc index 96d34f4a..598e67fb 100644 --- a/src/docs/asciidoc/user/bookmark-api.adoc +++ b/src/docs/asciidoc/user/bookmark-api.adoc @@ -2,6 +2,7 @@ ifndef::snippet[] :snippet: ../../../../build/generated-snippets endif::[] + === 유저가 북마크한 행사 확인 :sectnums!: ==== Request diff --git a/src/main/java/com/spaceclub/board/controller/CommentController.java b/src/main/java/com/spaceclub/board/controller/CommentController.java new file mode 100644 index 00000000..baeb8338 --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/CommentController.java @@ -0,0 +1,107 @@ +package com.spaceclub.board.controller; + +import com.spaceclub.board.controller.domain.Comment; +import com.spaceclub.board.controller.dto.CommentRequest; +import com.spaceclub.board.controller.dto.CommentResponse; +import com.spaceclub.board.service.BoardService; +import com.spaceclub.global.Authenticated; +import com.spaceclub.global.dto.SliceResponse; +import com.spaceclub.global.jwt.vo.JwtUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.List; + +import static org.springframework.data.domain.Sort.Direction.DESC; + +@RestController +@RequestMapping("/api/v1/boards/posts") +@RequiredArgsConstructor +public class CommentController { + + private final BoardService boardService; + + @GetMapping("/{postId}/comments") + public SliceResponse getCommentsByPaging( + @PageableDefault(sort = "createdAt", direction = DESC) Pageable pageable, + @PathVariable Long postId, + @Authenticated JwtUser jwtUser + ) { + // 댓글 페이징 조회 + Long userId = jwtUser.id(); + Slice commentPages = boardService.getComments(postId, pageable, userId); + List comments = commentPages.getContent().stream() + .map(CommentResponse::of) + .toList(); + + return new SliceResponse<>(comments, commentPages); + } + + @GetMapping("/{postId}/comments/{commentId}") + public CommentResponse getSingleComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @Authenticated JwtUser jwtUser + ) { + // 댓글 단건 조회 + Long userId = jwtUser.id(); + Comment comment = boardService.getComment(postId, commentId, userId); + + return CommentResponse.of(comment); + } + + @PostMapping("/{postId}/comments") + public ResponseEntity createComment( + @RequestBody CommentRequest commentRequest, + @PathVariable Long postId, + @Authenticated JwtUser jwtUser + ) { + // 댓글 생성 + Long userId = jwtUser.id(); + Long commentId = boardService.createComment(postId, commentRequest, userId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .location(URI.create("/api/v1/boards/posts/%d/comments/%d".formatted(postId, commentId))) + .build(); + } + + @PutMapping("/{postId}/comments/{commentId}") + public void updateComment( + @RequestBody CommentRequest commentRequest, + @PathVariable Long postId, + @PathVariable Long commentId, + @Authenticated JwtUser jwtUser + ) { + // 댓글 수정 + Long userId = jwtUser.id(); + boardService.updateComment(postId, commentId, commentRequest, userId); + } + + @ResponseStatus(code = HttpStatus.NO_CONTENT) + @DeleteMapping("/{postId}/comments/{commentId}") + public void deleteComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @Authenticated JwtUser jwtUser + ) { + // 댓글 삭제 + Long userId = jwtUser.id(); + boardService.deleteComment(postId, commentId, userId); + } + +} diff --git a/src/main/java/com/spaceclub/board/controller/PostController.java b/src/main/java/com/spaceclub/board/controller/PostController.java new file mode 100644 index 00000000..072f56bd --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/PostController.java @@ -0,0 +1,105 @@ +package com.spaceclub.board.controller; + +import com.spaceclub.board.controller.domain.Post; +import com.spaceclub.board.controller.dto.PostRequest; +import com.spaceclub.board.controller.dto.PostResponse; +import com.spaceclub.board.service.BoardService; +import com.spaceclub.global.Authenticated; +import com.spaceclub.global.dto.PageResponse; +import com.spaceclub.global.jwt.vo.JwtUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.util.List; + +import static org.springframework.data.domain.Sort.Direction.DESC; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NO_CONTENT; + +@RestController +@RequestMapping("/api/v1/boards/posts") +@RequiredArgsConstructor +public class PostController { + + private final BoardService boardService; + + @GetMapping("/{clubId}") //api/v1/boards/{clubId}?page={pageNum}&size={size}&sort=id,desc + public PageResponse getClubBoardPostsByPaging( + @PageableDefault(sort = "id", direction = DESC) Pageable pageable, + @PathVariable Long clubId, + @Authenticated JwtUser jwtUser) { + // 게시글 페이징 조회 + Page postPages = boardService.getClubBoardPostsByPaging(pageable, clubId, jwtUser.id()); + List posts = postPages.getContent().stream() + .map(PostResponse::from) + .toList(); + + return new PageResponse<>(posts, postPages); + } + + @GetMapping("/{clubId}/{postId}") + public PostResponse getSingleClubBoardPost( + @PathVariable Long clubId, + @PathVariable Long postId, + @Authenticated JwtUser jwtUser) { + // 게시글 단건 조회 + Long userId = jwtUser.id(); + Post post = boardService.getClubBoardPost(clubId, postId, userId); + + return PostResponse.from(post); + } + + @PostMapping(value = "/{clubId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createClubBoardPost( + @RequestPart(required = false) MultipartFile multipartFile, + @RequestPart PostRequest postRequest, + @PathVariable Long clubId, + @Authenticated JwtUser jwtUser) { + // 게시글 생성 + Long userId = jwtUser.id(); + Long postId = boardService.createClubBoardPost(clubId, postRequest, userId); + + return ResponseEntity + .status(CREATED) + .location(URI.create("/api/v1/boards/posts/%d/%d".formatted(clubId, postId))) + .build(); + } + + @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void updateClubBoardPost( + @RequestPart(required = false) MultipartFile multipartFile, + @RequestPart PostRequest postRequest, + @PathVariable Long postId, + @Authenticated JwtUser jwtUser) { + // 게시글 수정 + Long userId = jwtUser.id(); + boardService.updateClubBoardPost(postId, postRequest, userId); + } + + @DeleteMapping("/{postId}") + @ResponseStatus(code = NO_CONTENT) + public void deleteClubBoardPost( + @PathVariable Long postId, + @Authenticated JwtUser jwtUser) { + // 게시글 삭제 + Long userId = jwtUser.id(); + boardService.deleteClubBoardPost(postId, userId); + } + +} + diff --git a/src/main/java/com/spaceclub/board/controller/domain/Comment.java b/src/main/java/com/spaceclub/board/controller/domain/Comment.java new file mode 100644 index 00000000..da496fe8 --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/domain/Comment.java @@ -0,0 +1,40 @@ +package com.spaceclub.board.controller.domain; + +import com.spaceclub.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class Comment extends BaseTimeEntity { + + @Id + @Column(name = "comment_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; + + private Long authorId; + + private boolean isPrivate; + + private Long postId; + + @Builder + public Comment(Long id, String content, Long authorId, boolean isPrivate, Long postId) { + this.id = id; + this.content = content; + this.authorId = authorId; + this.isPrivate = isPrivate; + this.postId = postId; + } + +} diff --git a/src/main/java/com/spaceclub/board/controller/domain/Post.java b/src/main/java/com/spaceclub/board/controller/domain/Post.java new file mode 100644 index 00000000..f1b1e632 --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/domain/Post.java @@ -0,0 +1,40 @@ +package com.spaceclub.board.controller.domain; + +import com.spaceclub.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class Post extends BaseTimeEntity { + + @Id + @Column(name = "post_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String content; + + private String postImageUrl; + + private Long authorId; + + @Builder + public Post(Long id, String title, String content, String postImageUrl, Long authorId) { + this.id = id; + this.title = title; + this.content = content; + this.postImageUrl = postImageUrl; + this.authorId = authorId; + } + +} diff --git a/src/main/java/com/spaceclub/board/controller/dto/CommentRequest.java b/src/main/java/com/spaceclub/board/controller/dto/CommentRequest.java new file mode 100644 index 00000000..219c16b8 --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/dto/CommentRequest.java @@ -0,0 +1,8 @@ +package com.spaceclub.board.controller.dto; + +public record CommentRequest( + String content, + boolean isPrivate +) { + +} diff --git a/src/main/java/com/spaceclub/board/controller/dto/CommentResponse.java b/src/main/java/com/spaceclub/board/controller/dto/CommentResponse.java new file mode 100644 index 00000000..0ecc1f6f --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/dto/CommentResponse.java @@ -0,0 +1,31 @@ +package com.spaceclub.board.controller.dto; + +import com.spaceclub.board.controller.domain.Comment; + +import java.time.LocalDateTime; + +public record CommentResponse( + Long commentId, + String content, + Long authorId, + String author, + String authorImageUrl, + LocalDateTime createdDate, + LocalDateTime lastModifiedDate, + boolean isPrivate +) { + + public static CommentResponse of(Comment comment) { + return new CommentResponse( + comment.getId(), + comment.getContent(), + comment.getAuthorId(), + "authorName", + "authorImageUrl", + LocalDateTime.of(2023, 12, 31, 12, 0, 0), + LocalDateTime.of(2023, 12, 31, 12, 0, 0), + comment.isPrivate() + ); + } + +} diff --git a/src/main/java/com/spaceclub/board/controller/dto/PostRequest.java b/src/main/java/com/spaceclub/board/controller/dto/PostRequest.java new file mode 100644 index 00000000..80c39c22 --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/dto/PostRequest.java @@ -0,0 +1,8 @@ +package com.spaceclub.board.controller.dto; + +public record PostRequest( + String title, + String content +) { + +} diff --git a/src/main/java/com/spaceclub/board/controller/dto/PostResponse.java b/src/main/java/com/spaceclub/board/controller/dto/PostResponse.java new file mode 100644 index 00000000..a8cb546a --- /dev/null +++ b/src/main/java/com/spaceclub/board/controller/dto/PostResponse.java @@ -0,0 +1,40 @@ +package com.spaceclub.board.controller.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.spaceclub.board.controller.domain.Post; +import lombok.Builder; + +import java.time.LocalDateTime; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PostResponse( + Long postId, + String title, + String content, + Long authorId, + String author, + String authorImageUrl, + String postImageUrl, + LocalDateTime createdDate, + LocalDateTime lastModifiedDate +) { + + @Builder + public PostResponse { + } + + public static PostResponse from(Post post) { + return new PostResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getAuthorId(), + "authorName", + "authorImageUrl", + post.getPostImageUrl(), + LocalDateTime.of(2023, 12, 31, 12, 0, 0), + LocalDateTime.of(2023, 12, 31, 12, 0, 0) + ); + } + +} diff --git a/src/main/java/com/spaceclub/board/service/BoardService.java b/src/main/java/com/spaceclub/board/service/BoardService.java new file mode 100644 index 00000000..b92ce01f --- /dev/null +++ b/src/main/java/com/spaceclub/board/service/BoardService.java @@ -0,0 +1,61 @@ +package com.spaceclub.board.service; + +import com.spaceclub.board.controller.domain.Comment; +import com.spaceclub.board.controller.domain.Post; +import com.spaceclub.board.controller.dto.CommentRequest; +import com.spaceclub.board.controller.dto.PostRequest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +public class BoardService { + + public void updateClubBoardPost(Long postId, PostRequest postRequest, Long userId) { + // 게시글 수정 + } + + public void deleteClubBoardPost(Long postId, Long userId) { + // 게시글 삭제 + } + + public Page getClubBoardPostsByPaging(Pageable pageable, Long clubId, Long id) { + // 게시글 페이징 조회 + return null; + } + + public Slice getComments(Long postId, Pageable pageable, Long userId) { + // 댓글 페이징 조회 + return null; + } + + public Post getClubBoardPost(Long clubId, Long postId, Long userId) { + // 게시글 단건 조회 + return null; + } + + public Long createClubBoardPost(Long clubId, PostRequest postRequest, Long userId) { + // 게시글 생성 + return null; + } + + public Comment getComment(Long postId, Long commentId, Long userId) { + // 댓글 단건 조회 + return null; + } + + public Long createComment(Long postId, CommentRequest commentRequest, Long id) { + // 댓글 생성 + return null; + } + + public void updateComment(Long postId, Long commentId, CommentRequest commentRequest, Long userId) { + // 댓글 수정 + } + + public void deleteComment(Long postId, Long commentId, Long userId) { + // 댓글 삭제 + } + +} diff --git a/src/main/java/com/spaceclub/global/BaseTimeEntity.java b/src/main/java/com/spaceclub/global/BaseTimeEntity.java index 6ae1d9fc..6be20e8e 100644 --- a/src/main/java/com/spaceclub/global/BaseTimeEntity.java +++ b/src/main/java/com/spaceclub/global/BaseTimeEntity.java @@ -9,11 +9,11 @@ import java.time.LocalDateTime; +@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { - @Getter @CreatedDate protected LocalDateTime createdAt; diff --git a/src/main/java/com/spaceclub/global/dto/SliceResponse.java b/src/main/java/com/spaceclub/global/dto/SliceResponse.java new file mode 100644 index 00000000..dddef902 --- /dev/null +++ b/src/main/java/com/spaceclub/global/dto/SliceResponse.java @@ -0,0 +1,36 @@ +package com.spaceclub.global.dto; + +import org.springframework.data.domain.Slice; + +import java.util.List; + +public record SliceResponse( + List data, + PageableResponse sliceData +) { + + public SliceResponse(List data, Slice slice) { + this(data, new PageableResponse<>(slice)); + } + + private record PageableResponse( + boolean first, + boolean last, + int number, + int size, + int numberOfElements + ) { + + public PageableResponse(Slice slice) { + this( + slice.isFirst(), + slice.isLast(), + slice.getNumber(), + slice.getSize(), + slice.getNumberOfElements() + ); + } + + } + +} diff --git a/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java b/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java new file mode 100644 index 00000000..f76281c2 --- /dev/null +++ b/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java @@ -0,0 +1,310 @@ +package com.spaceclub.board.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spaceclub.SpaceClubCustomDisplayNameGenerator; +import com.spaceclub.board.controller.domain.Comment; +import com.spaceclub.board.controller.dto.CommentRequest; +import com.spaceclub.board.service.BoardService; +import com.spaceclub.global.UserArgumentResolver; +import com.spaceclub.global.interceptor.AuthenticationInterceptor; +import com.spaceclub.global.interceptor.AuthorizationInterceptor; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Slice; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + value = CommentController.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + AuthorizationInterceptor.class, + AuthenticationInterceptor.class + }) + }) +@AutoConfigureRestDocs +@DisplayNameGeneration(SpaceClubCustomDisplayNameGenerator.class) +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper mapper; + + @MockBean + private BoardService boardService; + + @MockBean + private UserArgumentResolver userArgumentResolver; + + @Test + @WithMockUser + void 댓글_전체_조회에_성공한다() throws Exception { + Long postId = 1L; + List comments = List.of( + Comment.builder() + .id(1L) + .content("content1") + .authorId(1L) + .isPrivate(false) + .postId(postId) + .build(), + Comment.builder() + .id(2L) + .content("content2") + .authorId(1L) + .isPrivate(false) + .postId(postId) + .build(), + Comment.builder() + .id(3L) + .content("content3") + .authorId(2L) + .isPrivate(true) + .postId(postId) + .build() + ); + + Slice commentPages = new PageImpl<>(comments); + given(boardService.getComments(any(), any(), any())).willReturn(commentPages); + + mockMvc.perform(get("/api/v1/boards/posts/{postId}/comments", postId) + .header(AUTHORIZATION, "access token") + .param("page", "0") + .param("size", "10") + .param("sort", "id,asc") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size()").value(comments.size())) + .andExpect(jsonPath("$.sliceData.first").value(true)) + .andExpect(jsonPath("$.sliceData.last").value(true)) + .andExpect(jsonPath("$.sliceData.number").value(0)) + .andExpect(jsonPath("$.sliceData.size").value(comments.size())) + .andExpect(jsonPath("$.sliceData.numberOfElements").value(comments.size())) + .andDo( + document("comment/getCommentsByPaging", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디") + ), + queryParameters( + parameterWithName("page").optional().description("페이지"), + parameterWithName("size").optional().description("페이지 내 개수, default 10"), + parameterWithName("sort").optional().description("정렬 방법(ex. id,desc), default createdAt,desc") + ), + responseFields( + fieldWithPath("data").type(ARRAY).description("페이지 내 댓글 정보"), + fieldWithPath("data[].commentId").type(NUMBER).description("댓글 아이디"), + fieldWithPath("data[].content").type(STRING).description("댓글 내용"), + fieldWithPath("data[].authorId").type(NUMBER).description("댓글 작성자 아이디"), + fieldWithPath("data[].author").type(STRING).description("댓글 작성자 이름"), + fieldWithPath("data[].authorImageUrl").type(STRING).description("댓글 작성자 프로필 이미지 URL"), + fieldWithPath("data[].createdDate").type(STRING).description("댓글 작성일"), + fieldWithPath("data[].lastModifiedDate").type(STRING).description("댓글 마지막 수정일"), + fieldWithPath("data[].isPrivate").type(BOOLEAN).description("비밀 댓글 여부"), + fieldWithPath("sliceData").type(OBJECT).description("페이지 정보"), + fieldWithPath("sliceData.first").type(BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("sliceData.last").type(BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("sliceData.number").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("sliceData.size").type(NUMBER).description("페이지 size (default 10)"), + fieldWithPath("sliceData.numberOfElements").type(NUMBER).description("페이지 내 댓글 개수") + ) + ) + ); + } + + @Test + @WithMockUser + void 댓글_단건_조회에_성공한다() throws Exception { + Long postId = 1L; + Long commentId = 1L; + Comment comment = Comment.builder() + .id(commentId) + .content("content1") + .authorId(1L) + .isPrivate(false) + .postId(postId) + .build(); + given(boardService.getComment(any(), any(), any())).willReturn(comment); + + mockMvc.perform(get("/api/v1/boards/posts/{postId}/comments/{commentId}", postId, commentId) + .header(AUTHORIZATION, "access token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentId").value(commentId)) + .andExpect(jsonPath("$.content").value(comment.getContent())) + .andExpect(jsonPath("$.authorId").value(comment.getAuthorId())) + .andExpect(jsonPath("$.author").value("authorName")) + .andExpect(jsonPath("$.authorImageUrl").value("authorImageUrl")) + .andExpect(jsonPath("$.createdDate").value("2023-12-31T12:00:00")) + .andExpect(jsonPath("$.lastModifiedDate").value("2023-12-31T12:00:00")) + .andExpect(jsonPath("$.isPrivate").value(comment.isPrivate())) + .andDo( + document("comment/getSingleComment", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디"), + parameterWithName("commentId").description("댓글 아이디") + ), + responseFields( + fieldWithPath("commentId").type(NUMBER).description("댓글 아이디"), + fieldWithPath("content").type(STRING).description("댓글 내용"), + fieldWithPath("authorId").type(NUMBER).description("댓글 작성자 아이디"), + fieldWithPath("author").type(STRING).description("댓글 작성자 이름"), + fieldWithPath("authorImageUrl").type(STRING).description("댓글 작성자 프로필 이미지 URL"), + fieldWithPath("createdDate").type(STRING).description("댓글 작성일"), + fieldWithPath("lastModifiedDate").type(STRING).description("댓글 마지막 수정일"), + fieldWithPath("isPrivate").type(BOOLEAN).description("비밀 댓글 여부") + ) + ) + ); + } + + @Test + @WithMockUser + void 댓글_생성에_성공한다() throws Exception { + Long commentId = 1L; + Long postId = 1L; + CommentRequest commentRequest = new CommentRequest("content1", false); + + given(boardService.createComment(any(), any(), any())).willReturn(commentId); + + mockMvc.perform(post("/api/v1/boards/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(commentRequest)) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andExpect(status().isCreated()) + .andExpect(header().stringValues("Location", "/api/v1/boards/posts/%d/comments/%d".formatted(postId, commentId))) + .andDo( + document("comment/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디") + ), + requestFields( + fieldWithPath("content").type(STRING).description("댓글 내용"), + fieldWithPath("isPrivate").type(BOOLEAN).description("비밀 댓글 여부") + ) + ) + ); + } + + @Test + @WithMockUser + void 댓글_수정에_성공한다() throws Exception { + Long postId = 1L; + Long commentId = 1L; + CommentRequest commentRequest = new CommentRequest("content1", false); + + doNothing().when(boardService).updateComment(any(), any(), any(), any()); + + mockMvc.perform(put("/api/v1/boards/posts/{postId}/comments/{commentId}", postId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(commentRequest)) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andExpect(status().isOk()) + .andDo( + document("comment/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디"), + parameterWithName("commentId").description("댓글 아이디") + ), + requestFields( + fieldWithPath("content").type(STRING).description("댓글 내용"), + fieldWithPath("isPrivate").type(BOOLEAN).description("비밀 댓글 여부") + ) + ) + ); + } + + @Test + @WithMockUser + void 댓글_삭제에_성공한다() throws Exception { + Long postId = 1L; + Long commentId = 1L; + + doNothing().when(boardService).deleteComment(any(), any(), any()); + + mockMvc.perform(delete("/api/v1/boards/posts/{postId}/comments/{commentId}", postId, commentId) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andExpect(status().isNoContent()) + .andDo( + document("comment/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디"), + parameterWithName("commentId").description("댓글 아이디") + ) + ) + ); + } + +} diff --git a/src/test/java/com/spaceclub/board/controller/PostControllerTest.java b/src/test/java/com/spaceclub/board/controller/PostControllerTest.java new file mode 100644 index 00000000..9ca0d361 --- /dev/null +++ b/src/test/java/com/spaceclub/board/controller/PostControllerTest.java @@ -0,0 +1,389 @@ +package com.spaceclub.board.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spaceclub.SpaceClubCustomDisplayNameGenerator; +import com.spaceclub.board.controller.domain.Post; +import com.spaceclub.board.controller.dto.PostRequest; +import com.spaceclub.board.service.BoardService; +import com.spaceclub.global.UserArgumentResolver; +import com.spaceclub.global.interceptor.AuthenticationInterceptor; +import com.spaceclub.global.interceptor.AuthorizationInterceptor; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpMethod.PUT; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + value = PostController.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + AuthorizationInterceptor.class, + AuthenticationInterceptor.class + }) + }) +@AutoConfigureRestDocs +@DisplayNameGeneration(SpaceClubCustomDisplayNameGenerator.class) +class PostControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper mapper; + + @MockBean + private BoardService boardService; + + @MockBean + private UserArgumentResolver userArgumentResolver; + + @Test + @WithMockUser + void 게시글_전체_조회에_성공한다() throws Exception { + List posts = List.of( + Post.builder() + .id(1L) + .title("title1") + .content("content1") + .postImageUrl("postImageUrl1") + .authorId(1L) + .build(), + Post.builder() + .id(2L) + .title("title2") + .content("content2") + .postImageUrl(null) + .authorId(1L) + .build(), + Post.builder() + .id(3L) + .title("title3") + .content("content3") + .postImageUrl("postImageUrl3") + .authorId(2L) + .build() + ); + + Page postPages = new PageImpl<>(posts); + given(boardService.getClubBoardPostsByPaging(any(), any(), any())).willReturn(postPages); + Long clubId = 1L; + + mockMvc.perform(get("/api/v1/boards/posts/{clubId}", clubId) + .header(AUTHORIZATION, "access token") + .param("page", "0") + .param("size", "10") + .param("sort", "id,asc") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size()").value(posts.size())) + .andExpect(jsonPath("$.pageData.first").value(true)) + .andExpect(jsonPath("$.pageData.last").value(true)) + .andExpect(jsonPath("$.pageData.pageNumber").value(0)) + .andExpect(jsonPath("$.pageData.size").value(posts.size())) + .andExpect(jsonPath("$.pageData.totalPages").value(1)) + .andExpect(jsonPath("$.pageData.totalElements").value(posts.size())) + .andDo( + document("post/getPostsByPaging", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("clubId").description("클럽 아이디") + ), + queryParameters( + parameterWithName("page").optional().description("페이지"), + parameterWithName("size").optional().description("페이지 내 개수, default 10"), + parameterWithName("sort").optional().description("정렬 방법(ex. id,desc), default id,desc") + ), + responseFields( + + fieldWithPath("data").type(ARRAY).description("페이지 내 게시글 정보"), + fieldWithPath("data[].postId").type(NUMBER).description("게시글 아이디"), + fieldWithPath("data[].title").type(STRING).description("게시글 제목"), + fieldWithPath("data[].content").type(STRING).description("게시글 내용"), + fieldWithPath("data[].authorId").type(NUMBER).description("게시글 작성자 아이디"), + fieldWithPath("data[].author").type(STRING).description("게시글 작성자 이름"), + fieldWithPath("data[].authorImageUrl").type(STRING).description("게시글 작성자 프로필 이미지 URL"), + fieldWithPath("data[].postImageUrl").type(STRING).optional().description("게시글 이미지 URL"), + fieldWithPath("data[].createdDate").type(STRING).description("게시글 작성일"), + fieldWithPath("data[].lastModifiedDate").type(STRING).description("게시글 마지막 수정일"), + fieldWithPath("pageData").type(OBJECT).description("페이지 정보"), + fieldWithPath("pageData.first").type(BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("pageData.last").type(BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("pageData.pageNumber").type(NUMBER).description("현재 페이지 번호"), + fieldWithPath("pageData.size").type(NUMBER).description("페이지 내 개수"), + fieldWithPath("pageData.totalPages").type(NUMBER).description("총 페이지 개수"), + fieldWithPath("pageData.totalElements").type(NUMBER).description("총 이벤트 개수") + ) + ) + ); + } + + @Test + @WithMockUser + void 게시글_단건_조회에_성공한다() throws Exception { + Post post = Post.builder() + .id(1L) + .title("title1") + .content("content1") + .postImageUrl("postImageUrl1") + .authorId(1L) + .build(); + given(boardService.getClubBoardPost(any(), any(), any())).willReturn(post); + Long clubId = 1L; + Long postId = 1L; + + mockMvc.perform(get("/api/v1/boards/posts/{clubId}/{postId}", clubId, postId) + .header(AUTHORIZATION, "access token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.postId").value(post.getId())) + .andExpect(jsonPath("$.title").value(post.getTitle())) + .andExpect(jsonPath("$.content").value(post.getContent())) + .andExpect(jsonPath("$.authorId").value(post.getAuthorId())) + .andExpect(jsonPath("$.author").value("authorName")) + .andExpect(jsonPath("$.authorImageUrl").value("authorImageUrl")) + .andExpect(jsonPath("$.postImageUrl").value(post.getPostImageUrl())) + .andExpect(jsonPath("$.createdDate").value("2023-12-31T12:00:00")) + .andExpect(jsonPath("$.lastModifiedDate").value("2023-12-31T12:00:00")) + .andDo( + document("post/getSinglePost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("clubId").description("클럽 아이디"), + parameterWithName("postId").description("게시글 아이디") + ), + responseFields( + fieldWithPath("postId").type(NUMBER).description("게시글 아이디"), + fieldWithPath("title").type(STRING).description("게시글 제목"), + fieldWithPath("content").type(STRING).description("게시글 내용"), + fieldWithPath("authorId").type(NUMBER).description("게시글 작성자 아이디"), + fieldWithPath("author").type(STRING).description("게시글 작성자 이름"), + fieldWithPath("authorImageUrl").type(STRING).description("게시글 작성자 프로필 이미지 URL"), + fieldWithPath("postImageUrl").type(STRING).optional().description("게시글 이미지 URL"), + fieldWithPath("createdDate").type(STRING).description("게시글 작성일"), + fieldWithPath("lastModifiedDate").type(STRING).description("게시글 마지막 수정일") + ) + ) + ); + } + + @Test + @WithMockUser + void 파일을_첨부한_게시글_생성에_성공한다() throws Exception { + Long clubId = 1L; + Long postId = 1L; + given(boardService.createClubBoardPost(any(), any(), any())).willReturn(1L); + PostRequest postRequest = new PostRequest("title1", "content1"); + MockMultipartFile multipartFile = + new MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "content".getBytes(StandardCharsets.UTF_8)); + MockMultipartFile postRequestFile = + new MockMultipartFile("postRequest", "", MediaType.APPLICATION_JSON_VALUE, mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart("/api/v1/boards/posts/{clubId}", clubId) + .file(multipartFile) + .file(postRequestFile) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().stringValues("Location", "/api/v1/boards/posts/%d/%d".formatted(clubId, postId))) + .andDo( + document("post/createWithImage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("clubId").description("클럽 아이디") + ), + requestParts( + partWithName("postRequest").description("게시글 제목 및 내용"), + partWithName("image").description("첨부 이미지").optional() + ), + requestPartFields("postRequest", + fieldWithPath("title").type(STRING).description("게시글 제목"), + fieldWithPath("content").type(STRING).description("게시글 내용") + ) + ) + ); + } + + @Test + @WithMockUser + void 파일을_첨부하지_않은_게시글_생성에_성공한다() throws Exception { + Long clubId = 1L; + Long postId = 1L; + given(boardService.createClubBoardPost(any(), any(), any())).willReturn(1L); + PostRequest postRequest = new PostRequest("title1", "content1"); + MockMultipartFile postRequestFile = + new MockMultipartFile("postRequest", "", MediaType.APPLICATION_JSON_VALUE, mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart("/api/v1/boards/posts/{clubId}", clubId) + .file(postRequestFile) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().stringValues("Location", "/api/v1/boards/posts/%d/%d".formatted(clubId, postId))) + + .andDo( + document("post/createWithoutImage", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("clubId").description("클럽 아이디") + ), + requestParts( + partWithName("postRequest").description("게시글 제목 및 내용") + ), + requestPartFields("postRequest", + fieldWithPath("title").type(STRING).description("게시글 제목"), + fieldWithPath("content").type(STRING).description("게시글 내용") + ) + ) + ); + } + + @Test + @WithMockUser + void 게시글_수정에_성공한다() throws Exception { + Long postId = 1L; + PostRequest postRequest = new PostRequest("title1", "content1"); + doNothing().when(boardService).updateClubBoardPost(any(Long.class), any(PostRequest.class), any(Long.class)); + + MockMultipartFile multipartFile = + new MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "content".getBytes(StandardCharsets.UTF_8)); + MockMultipartFile postRequestFile = + new MockMultipartFile("postRequest", "", MediaType.APPLICATION_JSON_VALUE, mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + var builder = (MockMultipartHttpServletRequestBuilder) multipart("/api/v1/boards/posts/{postId}", postId) + .with(request -> { + request.setMethod(PUT.name()); + return request; + }); + + mockMvc.perform((builder) + .file(multipartFile) + .file(postRequestFile) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andDo( + document("post/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + pathParameters( + parameterWithName("postId").description("게시글 아이디") + ), + requestParts( + partWithName("postRequest").description("게시글 제목 및 내용"), + partWithName("image").description("첨부 이미지").optional() + ), + requestPartFields("postRequest", + fieldWithPath("title").type(STRING).description("게시글 제목"), + fieldWithPath("content").type(STRING).description("게시글 내용") + ) + ) + ); + } + + @Test + @WithMockUser + void 게시글_삭제에_성공한다() throws Exception { + Long postId = 1L; + doNothing().when(boardService).deleteClubBoardPost(any(Long.class), any(Long.class)); + + mockMvc.perform(delete("/api/v1/boards/posts/{postId}", postId) + .header(AUTHORIZATION, "access token") + .with(csrf()) + ) + .andExpect(status().isNoContent()) + .andDo( + document("post/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("postId").description("게시글 아이디") + ), + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ) + ) + ); + } + +}