diff --git a/build.gradle b/build.gradle index 4db71c4f8..a9e9688e7 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,7 @@ java { } configurations { + asciidoctorExt compileOnly { extendsFrom annotationProcessor } @@ -33,9 +34,9 @@ dependencies { runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' implementation 'org.springframework.boot:spring-boot-starter-validation:3.0.4' - implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { @@ -45,5 +46,6 @@ tasks.named('test') { tasks.named('asciidoctor') { inputs.dir snippetsDir + configurations 'asciidoctorExt' dependsOn test } diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..a32f8bf64 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,76 @@ +:hardbreaks: +ifndef::snippets[] +:snippets: ../../build/generated-snippets +endif::[] + +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: +:sectnums: +:docinfo: shared-head + +== 게시글 + +=== 게시글 생성 + +==== [POST] /api/v1/posts + +.Request +include::{snippets}/post-save/http-request.adoc[] + +.Request Fields +include::{snippets}/post-save/request-fields.adoc[] + +.Response +include::{snippets}/post-save/http-response.adoc[] + +=== 게시글 조회 + +==== [GET] /api/v1/posts/{id} + +.Request +include::{snippets}/post-get-one/http-request.adoc[] + +.Response +include::{snippets}/post-get-one/http-response.adoc[] + +.Response Fields +include::{snippets}/post-get-one/response-fields.adoc[] + +=== 게시글 페이지 조회 + +==== [GET] /api/v1/posts?page={page}&size={size}&sort={sort} + +.Request +include::{snippets}/post-get-by-page/http-request.adoc[] + +.Response +include::{snippets}/post-get-by-page/http-response.adoc[] + +.Response Fields +include::{snippets}/post-get-by-page/response-fields.adoc[] + +=== 게시글 수정 + +==== [PATCH] /api/v1/posts/{id} + +.Request +include::{snippets}/post-update/http-request.adoc[] + +.Request Fields +include::{snippets}/post-update/request-fields.adoc[] + +.Response +include::{snippets}/post-update/http-response.adoc[] + +=== 게시글 삭제 + +==== [DELETE] /api/v1/posts/{id} + +.Request +include::{snippets}/post-delete/http-request.adoc[] + +.Response +include::{snippets}/post-delete/http-response.adoc[] diff --git a/src/docs/asciidoc/index.html b/src/docs/asciidoc/index.html new file mode 100644 index 000000000..20c7d46a1 --- /dev/null +++ b/src/docs/asciidoc/index.html @@ -0,0 +1,764 @@ + + + + + + + +게시글 + + + + + + + +
+
+

1. 게시글

+
+
+

1.1. 게시글 생성

+
+

1.1.1. [POST] /api/v1/posts

+
+
Request
+
+
POST /api/v1/posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 48
+Host: localhost:8080
+
+{"title":"title","content":"content","userId":1}
+
+
+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
Table 1. Request Fields
PathTypeDescription

title

String

제목

content

String

내용

userId

Number

유저 id

+
+
Response
+
+
HTTP/1.1 201 Created
+Location: /api/v1/posts/1
+
+
+
+
+
+

1.2. 게시글 조회

+
+

1.2.1. [GET] /api/v1/posts/{id}

+
+
Request
+
+
GET /api/v1/posts/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 55
+
+{"id":1,"title":"title","content":"content","userId":1}
+
+
+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Table 2. Response Fields
PathTypeDescription

id

Number

게시글 id

title

String

제목

content

String

내용

userId

Number

유저 id

+
+
+
+

1.3. 게시글 페이지 조회

+
+

1.3.1. [GET] /api/v1/posts?page={page}&size={size}&sort={sort}

+
+
Request
+
+
GET /api/v1/posts?page=0&size=2&sort=id%2Cdesc HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 236
+
+{"data":[{"id":2,"title":"title2","content":"content2","userId":1},{"id":1,"title":"title1","content":"content1","userId":1}],"pageable":{"first":true,"last":false,"number":0,"size":2,"sort":"id: DESC","totalPages":2,"totalElements":3}}
+
+
+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Table 3. Response Fields
PathTypeDescription

data[]

Array

데이터

data[].id

Number

게시글 id

data[].title

String

제목

data[].content

String

내용

data[].userId

Number

유저 id

pageable.first

Boolean

처음 페이지 여부

pageable.last

Boolean

마지막 페이지 여부

pageable.number

Number

페이지 번호

pageable.size

Number

페이지 당 게시글 개수

pageable.sort

String

정렬 기준

pageable.totalPages

Number

전체 페이지 수

pageable.totalElements

Number

전체 게시글 수

+
+
+
+

1.4. 게시글 수정

+
+

1.4.1. [PATCH] /api/v1/posts/{id}

+
+
Request
+
+
PATCH /api/v1/posts/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 45
+Host: localhost:8080
+
+{"title":"new-title","content":"new-content"}
+
+
+ + +++++ + + + + + + + + + + + + + + + + + + + +
Table 4. Request Fields
PathTypeDescription

title

String

제목

content

String

내용

+
+
Response
+
+
HTTP/1.1 204 No Content
+Location: /api/v1/posts/1
+
+
+
+
+
+

1.5. 게시글 삭제

+
+

1.5.1. [DELETE] /api/v1/posts/{id}

+
+
Request
+
+
DELETE /api/v1/posts/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 204 No Content
+
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/java/org/prgms/boardservice/domain/post/Content.java b/src/main/java/org/prgms/boardservice/domain/post/Content.java new file mode 100644 index 000000000..f32e2e208 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/Content.java @@ -0,0 +1,34 @@ +package org.prgms.boardservice.domain.post; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Lob; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static org.prgms.boardservice.util.ErrorMessage.INVALID_POST_CONTENT; +import static org.springframework.util.StringUtils.hasText; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Content { + + @Lob + @Column(nullable = false) + private String content; + + public Content(String content) { + validateContentLength(content); + this.content = content; + } + + private void validateContentLength(String value) { + if (!hasText(value) || value.length() > 500) { + throw new IllegalArgumentException(INVALID_POST_CONTENT.getMessage()); + } + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/Post.java b/src/main/java/org/prgms/boardservice/domain/post/Post.java index c5e066017..d1d1bc986 100644 --- a/src/main/java/org/prgms/boardservice/domain/post/Post.java +++ b/src/main/java/org/prgms/boardservice/domain/post/Post.java @@ -1,59 +1,41 @@ package org.prgms.boardservice.domain.post; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.DynamicUpdate; import org.prgms.boardservice.domain.BaseTime; -import static org.prgms.boardservice.util.ErrorMessage.INVALID_POST_CONTENT; -import static org.prgms.boardservice.util.ErrorMessage.INVALID_POST_TITLE; - +@Builder @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicUpdate +@EqualsAndHashCode(of = "id", callSuper = false) public class Post extends BaseTime { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(length = 20) - @NotBlank - private String title; + @Embedded + private Title title; - @Lob - @NotBlank - private String content; + @Embedded + private Content content; + @NotNull private Long userId; - public Post(String title, String content) { - validateTitleLength(title); - validateContentLength(content); - + public Post(Title title, Content content, Long userId) { this.title = title; this.content = content; + this.userId = userId; } - public void update(String title, String content) { - validateTitleLength(title); - validateContentLength(content); - + public void update(Title title, Content content) { this.title = title; this.content = content; } - - private void validateTitleLength(String title) { - if (!hasText(title) || title.length() > 20) { - throw new IllegalArgumentException(INVALID_POST_TITLE.getMessage()); - } - } - - private void validateContentLength(String content) { - if (!hasText(content) || content.length() > 500) { - throw new IllegalArgumentException(INVALID_POST_CONTENT.getMessage()); - } - } } diff --git a/src/main/java/org/prgms/boardservice/domain/post/PostController.java b/src/main/java/org/prgms/boardservice/domain/post/PostController.java new file mode 100644 index 000000000..2fc11127a --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/PostController.java @@ -0,0 +1,71 @@ +package org.prgms.boardservice.domain.post; + +import org.prgms.boardservice.domain.post.dto.PageResponse; +import org.prgms.boardservice.domain.post.dto.PostCreateRequest; +import org.prgms.boardservice.domain.post.dto.PostResponse; +import org.prgms.boardservice.domain.post.dto.PostUpdateRequest; +import org.prgms.boardservice.domain.post.vo.PostUpdate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.NoSuchElementException; + +@RestController +@RequestMapping("/api/v1/posts") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + @ExceptionHandler(NoSuchElementException.class) + private ResponseEntity noSuchElementExceptionHandle(NoSuchElementException exception) { + return ResponseEntity.badRequest() + .body(exception.getMessage()); + } + + @PostMapping + public ResponseEntity create(@RequestBody PostCreateRequest postCreateRequest) { + Long postId = postService.create(postCreateRequest.toEntity()); + + return ResponseEntity.created(URI.create("/api/v1/posts/" + postId)) + .build(); + } + + @GetMapping("/{id}") + public ResponseEntity getOne(@PathVariable Long id) throws NoSuchElementException { + PostResponse postResponse = new PostResponse(postService.getById(id)); + + return ResponseEntity.ok(postResponse); + } + + @GetMapping + public ResponseEntity> getPage(Pageable pageable) throws NoSuchElementException { + Page page = postService.getByPage(pageable); + Page postResponseDtoPage = page.map(PostResponse::new); + + return ResponseEntity.ok(new PageResponse<>(postResponseDtoPage)); + } + + @PatchMapping("/{id}") + public ResponseEntity update(@RequestBody PostUpdateRequest postUpdateRequest, @PathVariable Long id) { + Long postId = postService.update(new PostUpdate(id, postUpdateRequest.title(), postUpdateRequest.content())); + + return ResponseEntity.noContent() + .location(URI.create("/api/v1/posts/" + postId)) + .build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + postService.deleteById(id); + + return ResponseEntity.noContent() + .build(); + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/PostService.java b/src/main/java/org/prgms/boardservice/domain/post/PostService.java index 583844b70..9c1ef343c 100644 --- a/src/main/java/org/prgms/boardservice/domain/post/PostService.java +++ b/src/main/java/org/prgms/boardservice/domain/post/PostService.java @@ -1,6 +1,6 @@ package org.prgms.boardservice.domain.post; -import org.prgms.boardservice.domain.post.vo.PostUpdateVo; +import org.prgms.boardservice.domain.post.vo.PostUpdate; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -24,11 +24,11 @@ public Long create(Post post) { return postRepository.save(post).getId(); } - public Long update(PostUpdateVo postUpdateVo) { - Post findPost = postRepository.findById(postUpdateVo.id()) + public Long update(PostUpdate postUpdate) { + Post findPost = postRepository.findById(postUpdate.id()) .orElseThrow(() -> new NoSuchElementException(NOT_FOUND_POST.getMessage())); - findPost.update(postUpdateVo.title(), postUpdateVo.content()); + findPost.update(new Title(postUpdate.title()), new Content(postUpdate.content())); return postRepository.save(findPost).getId(); } @@ -45,6 +45,7 @@ public Page getByPage(Pageable pageable) { } public void deleteById(Long id) { + postRepository.findById(id).orElseThrow(() -> new NoSuchElementException(NOT_FOUND_POST.getMessage())); postRepository.deleteById(id); } diff --git a/src/main/java/org/prgms/boardservice/domain/post/Title.java b/src/main/java/org/prgms/boardservice/domain/post/Title.java new file mode 100644 index 000000000..9596dc59d --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/Title.java @@ -0,0 +1,32 @@ +package org.prgms.boardservice.domain.post; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static org.prgms.boardservice.util.ErrorMessage.INVALID_POST_TITLE; +import static org.springframework.util.StringUtils.hasText; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Title { + + @Column(length = 20, nullable = false) + private String title; + + public Title(String title) { + validateTitleLength(title); + this.title = title; + } + + private void validateTitleLength(String value) { + if (!hasText(value) || value.length() > 20) { + throw new IllegalArgumentException(INVALID_POST_TITLE.getMessage()); + } + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/dto/PageResponse.java b/src/main/java/org/prgms/boardservice/domain/post/dto/PageResponse.java new file mode 100644 index 000000000..b6642ade2 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/dto/PageResponse.java @@ -0,0 +1,36 @@ +package org.prgms.boardservice.domain.post.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List data, + PageableResponse pageable +) { + public PageResponse(Page page) { + this(page.getContent(), new PageableResponse<>(page)); + } + + private record PageableResponse( + boolean first, + boolean last, + int number, + int size, + String sort, + int totalPages, + long totalElements + ) { + private PageableResponse(Page page) { + this( + page.isFirst(), + page.isLast(), + page.getNumber(), + page.getSize(), + page.getSort().toString(), + page.getTotalPages(), + page.getTotalElements() + ); + } + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/dto/PostCreateRequest.java b/src/main/java/org/prgms/boardservice/domain/post/dto/PostCreateRequest.java new file mode 100644 index 000000000..134f6b291 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/dto/PostCreateRequest.java @@ -0,0 +1,16 @@ +package org.prgms.boardservice.domain.post.dto; + +import org.prgms.boardservice.domain.post.Content; +import org.prgms.boardservice.domain.post.Post; +import org.prgms.boardservice.domain.post.Title; + +public record PostCreateRequest(String title, String content, Long userId) { + + public Post toEntity() { + return Post.builder() + .title(new Title(title)) + .content(new Content(content)) + .userId(userId) + .build(); + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/dto/PostResponse.java b/src/main/java/org/prgms/boardservice/domain/post/dto/PostResponse.java new file mode 100644 index 000000000..ce6455c6b --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/dto/PostResponse.java @@ -0,0 +1,10 @@ +package org.prgms.boardservice.domain.post.dto; + +import org.prgms.boardservice.domain.post.Post; + +public record PostResponse(Long id, String title, String content, Long userId) { + + public PostResponse(Post post) { + this(post.getId(), post.getTitle().getTitle(), post.getContent().getContent(), post.getUserId()); + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/dto/PostUpdateRequest.java b/src/main/java/org/prgms/boardservice/domain/post/dto/PostUpdateRequest.java new file mode 100644 index 000000000..70480e2a4 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/dto/PostUpdateRequest.java @@ -0,0 +1,4 @@ +package org.prgms.boardservice.domain.post.dto; + +public record PostUpdateRequest(String title, String content) { +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdate.java b/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdate.java new file mode 100644 index 000000000..b11e15a61 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdate.java @@ -0,0 +1,4 @@ +package org.prgms.boardservice.domain.post.vo; + +public record PostUpdate(Long id, String title, String content) { +} diff --git a/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdateVo.java b/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdateVo.java deleted file mode 100644 index 1c0987a31..000000000 --- a/src/main/java/org/prgms/boardservice/domain/post/vo/PostUpdateVo.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.prgms.boardservice.domain.post.vo; - -public record PostUpdateVo(Long id, String title, String content) { -} diff --git a/src/main/java/org/prgms/boardservice/domain/user/Email.java b/src/main/java/org/prgms/boardservice/domain/user/Email.java new file mode 100644 index 000000000..2e924632d --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/user/Email.java @@ -0,0 +1,34 @@ +package org.prgms.boardservice.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.regex.Pattern; + +import static org.prgms.boardservice.util.ErrorMessage.INVALID_USER_EMAIL_PATTERN; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Email { + private static final String EMAIL_REGEX = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"; + + @Column(length = 20, unique = true) + private String email; + + public Email(String email) { + validateEmailPattern(email); + this.email = email; + } + + private void validateEmailPattern(String email) { + if (!Pattern.matches(EMAIL_REGEX, email)) { + throw new IllegalArgumentException(INVALID_USER_EMAIL_PATTERN.getMessage()); + } + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/user/Password.java b/src/main/java/org/prgms/boardservice/domain/user/Password.java new file mode 100644 index 000000000..68073c2f5 --- /dev/null +++ b/src/main/java/org/prgms/boardservice/domain/user/Password.java @@ -0,0 +1,36 @@ +package org.prgms.boardservice.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.regex.Pattern; + +import static org.prgms.boardservice.util.ErrorMessage.INVALID_USER_PASSWORD_PATTERN; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Password { + + private static final String PASSWORD_REGEX = "^.*(?=^.{8,15}$)(?=.*\\d)(?=.*[a-zA-Z])(?=.*[!@#$%^&+=]).*$"; + + @Column(length = 100, nullable = false) + private String password; + + public Password(String password) { + validatePasswordPattern(password); + this.password = password; + } + + private void validatePasswordPattern(String password) { + if (!Pattern.matches(PASSWORD_REGEX, password)) { + throw new IllegalArgumentException(INVALID_USER_PASSWORD_PATTERN.getMessage()); + } + } +} diff --git a/src/main/java/org/prgms/boardservice/domain/user/User.java b/src/main/java/org/prgms/boardservice/domain/user/User.java index cb3154ae8..b1badc000 100644 --- a/src/main/java/org/prgms/boardservice/domain/user/User.java +++ b/src/main/java/org/prgms/boardservice/domain/user/User.java @@ -3,78 +3,46 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import org.prgms.boardservice.domain.BaseTime; -import org.prgms.boardservice.domain.post.Post; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; - -import static org.prgms.boardservice.util.ErrorMessage.*; +import static org.prgms.boardservice.util.ErrorMessage.INVALID_USER_NICKNAME_LENGTH; @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id", callSuper = false) public class User extends BaseTime { - private static final String EMAIL_REGEX = "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"; - private static final String PASSWORD_REGEX = "^.*(?=^.{8,15}$)(?=.*\\d)(?=.*[a-zA-Z])(?=.*[!@#$%^&+=]).*$"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") private Long id; - @Column(length = 20, unique = true) - @NotBlank - private String email; + @Embedded + private Email email; - @Column(length = 100) - @NotBlank - private String password; + @Embedded + private Password password; @Column(length = 10, unique = true) @NotBlank private String nickname; - @OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST) - private List posts = new ArrayList<>(); - - public User(String email, String password, String nickname) { - validateEmailPattern(email); - validatePasswordPattern(password); + public User(Email email, Password password, String nickname) { validateNicknameLength(nickname); this.email = email; - this.password = encodePassword(password); + this.password = password; this.nickname = nickname; } - private void validateEmailPattern(String email) { - if (!Pattern.matches(EMAIL_REGEX, email)) { - throw new IllegalArgumentException(INVALID_USER_EMAIL_PATTERN.getMessage()); - } - } - - private void validatePasswordPattern(String password) { - if (!Pattern.matches(PASSWORD_REGEX, password)) { - throw new IllegalArgumentException(INVALID_USER_PASSWORD_PATTERN.getMessage()); - } - } - private void validateNicknameLength(String nickname) { if (nickname.length() < 2 || nickname.length() > 10) { throw new IllegalArgumentException(INVALID_USER_NICKNAME_LENGTH.getMessage()); } } - - private String encodePassword(String password) { - PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - return passwordEncoder.encode(password); - } } diff --git a/src/test/java/org/prgms/boardservice/domain/post/PostControllerTest.java b/src/test/java/org/prgms/boardservice/domain/post/PostControllerTest.java new file mode 100644 index 000000000..c78a83ec6 --- /dev/null +++ b/src/test/java/org/prgms/boardservice/domain/post/PostControllerTest.java @@ -0,0 +1,212 @@ +package org.prgms.boardservice.domain.post; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.prgms.boardservice.domain.post.dto.PostCreateRequest; +import org.prgms.boardservice.domain.post.dto.PostUpdateRequest; +import org.prgms.boardservice.domain.post.vo.PostUpdate; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +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.*; + +@WebMvcTest +@AutoConfigureRestDocs +class PostControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private PostService postService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("게시글이 성공적으로 생성된다.") + void success_Save_Post() throws Exception { + PostCreateRequest requestDto = new PostCreateRequest("title", "content", 1L); + + // given + given(postService.create(any(Post.class))).willReturn(1L); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(header().stringValues("Location", "/api/v1/posts/1")) + .andDo(print()) + .andDo(document("post-save", + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("userId").type(JsonFieldType.NUMBER).description("유저 id") + ), + responseHeaders( + headerWithName("location").description("리소스 위치") + )) + ); + } + + @Test + @DisplayName("게시글을 id로 조회할 수 있다.") + void success_Get_One_Post() throws Exception { + // given + Long postId = 1L; + Post post = new Post(postId, new Title("title"), new Content("content"), 1L); + + given(postService.getById(postId)).willReturn(post); + + // when + ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/posts/{id}", postId)); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("id").value(post.getId())) + .andExpect(jsonPath("title").value(post.getTitle())) + .andExpect(jsonPath("content").value(post.getContent())) + .andExpect(jsonPath("userId").value(post.getUserId())) + .andDo(print()) + .andDo(document("post-get-one", + pathParameters( + parameterWithName("id").description("게시글 id") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER).description("게시글 id"), + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("userId").type(JsonFieldType.NUMBER).description("유저 id") + )) + ); + } + + @Test + @DisplayName("게시글을 페이지로 조회할 수 있다.") + void success_Get_Page_Post() throws Exception { + // given + PageRequest pageRequest = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "id")); + + Post post1 = new Post(1L, new Title("title1"), new Content("content1"), 1L); + Post post2 = new Post(2L, new Title("title2"), new Content("content2"), 1L); + + Page posts = new PageImpl<>(List.of(post2, post1), pageRequest, 3); + + given(postService.getByPage(pageRequest)).willReturn(posts); + + // when + ResultActions resultActions = mockMvc.perform(get("/api/v1/posts") + .param("page", String.valueOf(0)) + .param("size", String.valueOf(2)) + .param("sort", "id,desc")); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(post2.getId())) + .andExpect(jsonPath("$.data[1].id").value(post1.getId())) + .andExpect(jsonPath("$.pageable.first").value(true)) + .andExpect(jsonPath("$.pageable.last").value(false)) + .andExpect(jsonPath("$.pageable.number").value(0)) + .andExpect(jsonPath("$.pageable.size").value(2)) + .andExpect(jsonPath("$.pageable.totalPages").value(2)) + .andExpect(jsonPath("$.pageable.totalElements").value(3)) + .andExpect(jsonPath("$.pageable.sort").value("id: DESC")) + .andDo(print()) + .andDo(document("post-get-by-page", + responseFields( + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("데이터"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("게시글 id"), + fieldWithPath("data[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data[].content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data[].userId").type(JsonFieldType.NUMBER).description("유저 id"), + fieldWithPath("pageable.first").type(JsonFieldType.BOOLEAN).description("처음 페이지 여부"), + fieldWithPath("pageable.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("pageable.number").type(JsonFieldType.NUMBER).description("페이지 번호"), + fieldWithPath("pageable.size").type(JsonFieldType.NUMBER).description("페이지 당 게시글 개수"), + fieldWithPath("pageable.sort").type(JsonFieldType.STRING).description("정렬 기준"), + fieldWithPath("pageable.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("pageable.totalElements").type(JsonFieldType.NUMBER).description("전체 게시글 수") + )) + ); + } + + @Test + @DisplayName("게시글이 성공적으로 수정된다.") + void success_Update_Post() throws Exception { + Long postId = 1L; + PostUpdateRequest requestDto = new PostUpdateRequest("new-title", "new-content"); + + // given + given(postService.update(any(PostUpdate.class))).willReturn(1L); + + // when + ResultActions resultActions = mockMvc.perform(patch("/api/v1/posts/{id}", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))); + + // then + resultActions + .andExpect(status().isNoContent()) + .andExpect(header().stringValues("Location", "/api/v1/posts/1")) + .andDo(print()) + .andDo(document("post-update", + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("content").type(JsonFieldType.STRING).description("내용") + ), + responseHeaders( + headerWithName("location").description("리소스 위치") + )) + ); + } + + @Test + @DisplayName("게시글을 id로 삭제할 수 있다.") + void success_Delete_Post() throws Exception { + // given + Long postId = 1L; + + // when + ResultActions resultActions = mockMvc.perform(RestDocumentationRequestBuilders.delete("/api/v1/posts/{id}", postId)); + + // then + resultActions + .andExpect(status().isNoContent()) + .andDo(print()) + .andDo(document("post-delete", + pathParameters( + parameterWithName("id").description("게시글 id") + ) + )); + } +} diff --git a/src/test/java/org/prgms/boardservice/domain/post/PostServiceTest.java b/src/test/java/org/prgms/boardservice/domain/post/PostServiceTest.java index 2833cd45c..398c26ce1 100644 --- a/src/test/java/org/prgms/boardservice/domain/post/PostServiceTest.java +++ b/src/test/java/org/prgms/boardservice/domain/post/PostServiceTest.java @@ -1,10 +1,9 @@ package org.prgms.boardservice.domain.post; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.prgms.boardservice.domain.post.vo.PostUpdateVo; +import org.prgms.boardservice.domain.post.vo.PostUpdate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; @@ -26,7 +25,7 @@ class PostServiceTest { @Autowired PostService postService; - private final Post post = new Post("title", "content", 1L); + private final Post post = new Post(new Title("title"), new Content("content"), 1L); @BeforeEach void setUp() { @@ -36,7 +35,7 @@ void setUp() { @Test @DisplayName("게시글이 성공적으로 생성된다.") void success_Create_Post() { - Long id = postService.create(new Post("title", "content", 1L)); + Long id = postService.create(new Post(new Title("title"), new Content("content"), 1L)); Post get = postService.getById(id); @@ -48,23 +47,23 @@ void success_Create_Post() { @Test @DisplayName("게시글이 성공적으로 수정된다.") void success_Update_Post() { - PostUpdateVo postUpdateVo = new PostUpdateVo(post.getId(), "new-title", "new-content"); + PostUpdate postUpdate = new PostUpdate(post.getId(), "new-title", "new-content"); - Long id = postService.update(postUpdateVo); + Long id = postService.update(postUpdate); Post updated = postService.getById(id); assertThat(updated).usingRecursiveComparison() .comparingOnlyFields("id", "title", "content") - .isEqualTo(postUpdateVo); + .isEqualTo(postUpdate); } @Test @DisplayName("존재하지 않는 게시글은 수정에 실패한다.") void fail_Update_Post() { - PostUpdateVo postUpdateVo = new PostUpdateVo(Long.MAX_VALUE, "title", "content"); + PostUpdate postUpdate = new PostUpdate(Long.MAX_VALUE, "title", "content"); - assertThatThrownBy(() -> postService.update(postUpdateVo)) + assertThatThrownBy(() -> postService.update(postUpdate)) .isInstanceOf(NoSuchElementException.class) .hasMessage(NOT_FOUND_POST.getMessage()); } @@ -90,8 +89,8 @@ void fail_Get_Post() { @Test @DisplayName("게시글을 페이징하여 조회할 수 있다.") void success_Get_By_Page() { - Post post1 = new Post("title1", "content1", 1L); - Post post2 = new Post("title2", "content2", 1L); + Post post1 = new Post(1L, new Title("title1"), new Content("content1"), 1L); + Post post2 = new Post(2L, new Title("title2"), new Content("content2"), 1L); postService.create(post1); postService.create(post2); diff --git a/src/test/java/org/prgms/boardservice/domain/post/PostTest.java b/src/test/java/org/prgms/boardservice/domain/post/PostTest.java index e85ef0d78..efa5bd374 100644 --- a/src/test/java/org/prgms/boardservice/domain/post/PostTest.java +++ b/src/test/java/org/prgms/boardservice/domain/post/PostTest.java @@ -3,9 +3,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; +import java.util.stream.Stream; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.prgms.boardservice.util.ErrorMessage.INVALID_POST_CONTENT; @@ -14,8 +18,8 @@ class PostTest { private final Long userId = 1L; - private final String titleMoreThenTwentyLength = "TitleMoreThenTwentyLength"; - private final String contentMoreThanFiveHundredLength = "sit amet cursus sit amet dictum sit amet justo donec enim diam vulputate ut pharetra sit amet aliquam id diam maecenas ultricies mi eget mauris pharetra et ultrices neque ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae proin sagittis nisl rhoncus mattis rhoncus urna neque viverra justo nec ultrices dui sapien eget mi proin sed libero enim sed faucibus turpis in eu mi bibendum neque egestas congue quisque egestas diam in arcu cursus euismod quis viverra nibh cras pulvinar mattis nunc sed blandit libero volutpat sed cras ornare arcu dui vivamus arcu felis bibendum ut tristique et egestas"; + private static final String titleMoreThenTwentyLength = "TitleMoreThenTwentyLength"; + private static final String contentMoreThanFiveHundredLength = "sit amet cursus sit amet dictum sit amet justo donec enim diam vulputate ut pharetra sit amet aliquam id diam maecenas ultricies mi eget mauris pharetra et ultrices neque ornare aenean euismod elementum nisi quis eleifend quam adipiscing vitae proin sagittis nisl rhoncus mattis rhoncus urna neque viverra justo nec ultrices dui sapien eget mi proin sed libero enim sed faucibus turpis in eu mi bibendum neque egestas congue quisque egestas diam in arcu cursus euismod quis viverra nibh cras pulvinar mattis nunc sed blandit libero volutpat sed cras ornare arcu dui vivamus arcu felis bibendum ut tristique et egestas"; @ParameterizedTest(name = "test with value [{0}]") @NullAndEmptySource @@ -24,7 +28,7 @@ class PostTest { void fail_Post_Constructor_With_Invalid_Title(String title) { String content = "content"; - assertThatThrownBy(() -> new Post(title, content, userId)) + assertThatThrownBy(() -> new Post(new Title(title), new Content(content), userId)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(INVALID_POST_TITLE.getMessage()); } @@ -36,7 +40,7 @@ void fail_Post_Constructor_With_Invalid_Title(String title) { void fail_Post_Constructor_With_Invalid_Content(String content) { String title = "title"; - assertThatThrownBy(() -> new Post(title, content, userId)) + assertThatThrownBy(() ->new Post(new Title(title), new Content(content), userId)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(INVALID_POST_CONTENT.getMessage()); } @@ -44,32 +48,28 @@ void fail_Post_Constructor_With_Invalid_Content(String content) { @Test @DisplayName("게시글이 성공적으로 생성된다.") void success_New_Post() { - Post post = new Post("title", "content", userId); + Post post = new Post(new Title("title"), new Content("content"), userId); assertThat(post).isNotNull(); } - @ParameterizedTest(name = "test with value [{0}]") - @NullAndEmptySource - @ValueSource(strings = {" ", titleMoreThenTwentyLength}) - @DisplayName("게시글을 유효하지 않은 제목으로 변경할 수 없다.") - void fail_Change_Post_With_Invalid_Title(String title) { - Post post = new Post("title", "content", userId); + @ParameterizedTest(name = "title: {0}, content: {1}") + @MethodSource("postInvalidInfo") + @DisplayName("게시글을 유효하지 않은 제목, 내용으로 변경할 수 없다.") + void fail_Change_Post_With_Invalid_Title(String title, String content) { + Post post = new Post(new Title("title"), new Content("content"), userId); - assertThatThrownBy(() -> post.changeTitle(title)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(INVALID_POST_TITLE.getMessage()); + assertThatThrownBy(() -> post.update(new Title(title), new Content(content))) + .isInstanceOf(IllegalArgumentException.class); } - @ParameterizedTest(name = "test with value [{0}]") - @NullAndEmptySource - @ValueSource(strings = {" ", contentMoreThanFiveHundredLength}) - @DisplayName("게시글을 유효하지 않은 내용으로 변경할 수 없다.") - void fail_Change_Post_With_Invalid_Content(String content) { - Post post = new Post("title", "content", userId); - - assertThatThrownBy(() -> post.changeContent(content)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(INVALID_POST_CONTENT.getMessage()); + static Stream postInvalidInfo() { + return Stream.of( + Arguments.arguments(null, null), + Arguments.arguments("", ""), + Arguments.arguments(" ", " "), + Arguments.arguments(titleMoreThenTwentyLength, "content"), + Arguments.arguments("title", contentMoreThanFiveHundredLength) + ); } }