diff --git a/mysql/schema.sql b/mysql/schema.sql index bb7b278d..35e9beee 100644 --- a/mysql/schema.sql +++ b/mysql/schema.sql @@ -12,20 +12,19 @@ CREATE TABLE users certified TINYINT(1) NOT NULL, created_at DATETIME(6) NULL, updated_at DATETIME(6) NULL, - CONSTRAINT users_pk - PRIMARY KEY (id), - CONSTRAINT users_email_index - UNIQUE (email) + + CONSTRAINT users_pk PRIMARY KEY (id), + CONSTRAINT users_email_index UNIQUE (email) ); CREATE TABLE avatar_items ( - id BIGINT AUTO_INCREMENT, - image VARCHAR(500) NOT NULL, - type VARCHAR(30) NOT NULL, - sex VARCHAR(10) NOT NULL, - CONSTRAINT avatar_items_pk - PRIMARY KEY (id) + id BIGINT AUTO_INCREMENT, + image_url VARCHAR(500) NOT NULL, + type VARCHAR(30) NOT NULL, + sex VARCHAR(10) NOT NULL, + + CONSTRAINT avatar_items_pk PRIMARY KEY (id) ); CREATE TABLE posts @@ -34,16 +33,15 @@ CREATE TABLE posts user_id BIGINT NOT NULL, title VARCHAR(30) NOT NULL, content VARCHAR(255) NOT NULL, - image VARCHAR(500) NULL, + image_url VARCHAR(500) NULL, created_at DATETIME(6) NULL, updated_at DATETIME(6) NULL, like_cnt INTEGER NULL, min_temperature DOUBLE NOT NULL, max_temperature DOUBLE NOT NULL, - CONSTRAINT posts_pk - PRIMARY KEY (id), - FOREIGN KEY (user_id) - REFERENCES users (id) + + CONSTRAINT posts_pk PRIMARY KEY (id), + FOREIGN KEY (user_id) REFERENCES users (id) ); CREATE TABLE likes @@ -54,10 +52,8 @@ CREATE TABLE likes is_like TINYINT NOT NULL, created_at DATETIME(6) NULL, updated_at DATETIME(6) NULL, - CONSTRAINT posts_pk - PRIMARY KEY (id), - FOREIGN KEY (user_id) - REFERENCES users (id), - FOREIGN KEY (post_id) - REFERENCES posts (id) + + CONSTRAINT posts_pk PRIMARY KEY (id), + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (post_id) REFERENCES posts (id) ); diff --git a/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java b/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java index 473f6084..67b1a625 100644 --- a/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java +++ b/src/main/java/com/backendoori/ootw/avatar/domain/AvatarItem.java @@ -28,8 +28,8 @@ public class AvatarItem { @Column(name = "id") private Long id; - @Column(name = "image", nullable = false) - private String image; + @Column(name = "image_url", nullable = false) + private String imageUrl; @Column(name = "type", nullable = false, columnDefinition = "varchar(30)") @Enumerated(EnumType.STRING) @@ -39,12 +39,12 @@ public class AvatarItem { @Enumerated(EnumType.STRING) private Sex sex; - private AvatarItem(String image, String type, String sex) { - validateImage(image); + private AvatarItem(String imageUrl, String type, String sex) { + validateImage(imageUrl); validateItemType(type); validateSex(sex); - this.image = image; + this.imageUrl = imageUrl; this.itemType = ItemType.valueOf(type); this.sex = Sex.valueOf(sex); } diff --git a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java index fd51ad4f..bb0bc156 100644 --- a/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java +++ b/src/main/java/com/backendoori/ootw/avatar/dto/AvatarItemResponse.java @@ -14,7 +14,7 @@ public static AvatarItemResponse from(AvatarItem avatarItem) { avatarItem.getId(), avatarItem.getItemType().name(), avatarItem.getSex().name(), - avatarItem.getImage()); + avatarItem.getImageUrl()); } } diff --git a/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java b/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java index a5b7ba6a..5ead86ba 100644 --- a/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java +++ b/src/main/java/com/backendoori/ootw/config/HttpRequestsConfigurer.java @@ -1,6 +1,9 @@ package com.backendoori.ootw.config; +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; + import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; @@ -10,6 +13,8 @@ public class HttpRequestsConfigurer implements Customizer.AuthorizationManagerRequestMatcherRegistry> { private static final String AUTH_RESOURCE = "/api/v1/auth/**"; + private static final String POST_RESOURCE = "/api/v1/posts/**"; + private static final String AVATAR_RESOURCE = "/api/v1/avatar-items/**"; @Override public void customize( @@ -17,6 +22,10 @@ public void customize( authorizeRequests .requestMatchers(AUTH_RESOURCE) .permitAll() + .requestMatchers(antMatcher(HttpMethod.GET, POST_RESOURCE)) + .permitAll() + .requestMatchers(antMatcher(HttpMethod.GET, AVATAR_RESOURCE)) + .permitAll() .anyRequest() .authenticated(); } diff --git a/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java b/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java index e59d4474..ba402c45 100644 --- a/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java +++ b/src/main/java/com/backendoori/ootw/exception/GlobalControllerAdvice.java @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; @Slf4j @RestControllerAdvice @@ -21,6 +23,14 @@ public class GlobalControllerAdvice { public static final String DEFAULT_MESSAGE = "유효하지 않은 요청 입니다."; + @ExceptionHandler({MissingServletRequestPartException.class, MultipartException.class}) + public ResponseEntity handleMissingServletRequestPartException(Exception e) { + ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(errorResponse); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); @@ -65,6 +75,14 @@ public ResponseEntity handleAuthenticationException(Authenticatio .body(errorResponse); } + @ExceptionHandler(PermissionException.class) + public ResponseEntity handlePermissionException(PermissionException e) { + ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); + + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(errorResponse); + } + @ExceptionHandler(NoSuchElementException.class) public ResponseEntity handleNoSuchElementException(NoSuchElementException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); diff --git a/src/main/java/com/backendoori/ootw/exception/PermissionException.java b/src/main/java/com/backendoori/ootw/exception/PermissionException.java new file mode 100644 index 00000000..acab05a6 --- /dev/null +++ b/src/main/java/com/backendoori/ootw/exception/PermissionException.java @@ -0,0 +1,11 @@ +package com.backendoori.ootw.exception; + +public class PermissionException extends RuntimeException { + + public static final String DEFAULT_MESSAGE = "요청에 대한 권한이 없습니다."; + + public PermissionException() { + super(DEFAULT_MESSAGE); + } + +} diff --git a/src/main/java/com/backendoori/ootw/post/controller/PostController.java b/src/main/java/com/backendoori/ootw/post/controller/PostController.java index bf6bb2ac..91946cb6 100644 --- a/src/main/java/com/backendoori/ootw/post/controller/PostController.java +++ b/src/main/java/com/backendoori/ootw/post/controller/PostController.java @@ -3,17 +3,21 @@ import java.net.URI; import java.util.List; import com.backendoori.ootw.common.validation.Image; -import com.backendoori.ootw.post.dto.PostReadResponse; -import com.backendoori.ootw.post.dto.PostSaveRequest; -import com.backendoori.ootw.post.dto.PostSaveResponse; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostUpdateRequest; +import com.backendoori.ootw.post.dto.response.PostReadResponse; +import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse; import com.backendoori.ootw.post.service.PostService; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -26,29 +30,51 @@ public class PostController { private final PostService postService; + @GetMapping("/{postId}") + public ResponseEntity readDetailByPostId(@PathVariable @Positive Long postId) { + return ResponseEntity.status(HttpStatus.OK) + .body(postService.getDetailByPostId(postId)); + } + + @GetMapping + public ResponseEntity> readAll() { + return ResponseEntity.status(HttpStatus.OK) + .body(postService.getAll()); + } + + @DeleteMapping("/{postId}") + public ResponseEntity delete(@PathVariable @Positive Long postId) { + postService.delete(postId); + + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .build(); + } + @PostMapping - public ResponseEntity save( + public ResponseEntity save( @RequestPart(required = false) @Image(ignoreCase = true) MultipartFile postImg, @RequestPart @Valid PostSaveRequest request) { - PostSaveResponse response = postService.save(request, postImg); - - URI postUri = URI.create("/api/v1/posts/" + response.postId()); + PostSaveUpdateResponse response = postService.save(request, postImg); return ResponseEntity.status(HttpStatus.CREATED) - .location(postUri) + .location(getPostUri(response.postId())) .body(response); } - @GetMapping("/{postId}") - public ResponseEntity readDetailByPostId(@PathVariable Long postId) { - return ResponseEntity.status(HttpStatus.OK) - .body(postService.getDetailByPostId(postId)); + @PutMapping("/{postId}") + public ResponseEntity update( + @PathVariable @Positive Long postId, + @RequestPart(required = false) @Image(ignoreCase = true) MultipartFile postImg, + @RequestPart @Valid PostUpdateRequest request) { + PostSaveUpdateResponse response = postService.update(postId, postImg, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .location(getPostUri(response.postId())) + .body(response); } - @GetMapping - public ResponseEntity> readAll() { - return ResponseEntity.status(HttpStatus.OK) - .body(postService.getAll()); + private URI getPostUri(Long postId) { + return URI.create("/api/v1/posts/" + postId); } } diff --git a/src/main/java/com/backendoori/ootw/post/domain/Post.java b/src/main/java/com/backendoori/ootw/post/domain/Post.java index 26f61a26..6786a08f 100644 --- a/src/main/java/com/backendoori/ootw/post/domain/Post.java +++ b/src/main/java/com/backendoori/ootw/post/domain/Post.java @@ -1,11 +1,13 @@ package com.backendoori.ootw.post.domain; +import static com.backendoori.ootw.post.validation.PostValidator.validateContent; import static com.backendoori.ootw.post.validation.PostValidator.validatePostSaveRequest; import static com.backendoori.ootw.post.validation.PostValidator.validateTemperatureArrange; +import static com.backendoori.ootw.post.validation.PostValidator.validateTitle; import static com.backendoori.ootw.post.validation.PostValidator.validateUser; import com.backendoori.ootw.common.BaseEntity; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.weather.domain.TemperatureArrange; import jakarta.persistence.Column; @@ -43,8 +45,8 @@ public class Post extends BaseEntity { @Column(name = "content", nullable = false) private String content; - @Column(name = "image") - private String image; + @Column(name = "image_url") + private String imageUrl; @Embedded private TemperatureArrange temperatureArrange; @@ -60,7 +62,7 @@ private Post(User user, PostSaveRequest request, String imgUrl, TemperatureArran this.user = user; this.title = request.title(); this.content = request.content(); - this.image = imgUrl; + this.imageUrl = imgUrl; this.temperatureArrange = temperatureArrange; } @@ -76,4 +78,17 @@ public void decreaseLikeCnt() { this.likeCnt--; } + public void updateTitle(String title) { + validateTitle(title); + this.title = title; + } + + public void updateContent(String content) { + validateContent(content); + this.content = content; + } + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java b/src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java similarity index 94% rename from src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java rename to src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java index c19fd1d9..85767b23 100644 --- a/src/main/java/com/backendoori/ootw/post/dto/PostSaveRequest.java +++ b/src/main/java/com/backendoori/ootw/post/dto/request/PostSaveRequest.java @@ -1,4 +1,4 @@ -package com.backendoori.ootw.post.dto; +package com.backendoori.ootw.post.dto.request; import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT; import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE; diff --git a/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java b/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java new file mode 100644 index 00000000..035d6244 --- /dev/null +++ b/src/main/java/com/backendoori/ootw/post/dto/request/PostUpdateRequest.java @@ -0,0 +1,21 @@ +package com.backendoori.ootw.post.dto.request; + +import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT; +import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE; +import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT; +import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PostUpdateRequest( + @NotBlank(message = BLANK_POST_TITLE) + @Size(max = 30, message = INVALID_POST_TITLE) + String title, + + @NotBlank(message = BLANK_POST_CONTENT) + @Size(max = 500, message = INVALID_POST_CONTENT) + String content +) { + +} diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java b/src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java similarity index 92% rename from src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java rename to src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java index 1d0e2f91..f54fdc3a 100644 --- a/src/main/java/com/backendoori/ootw/post/dto/PostReadResponse.java +++ b/src/main/java/com/backendoori/ootw/post/dto/response/PostReadResponse.java @@ -1,7 +1,6 @@ -package com.backendoori.ootw.post.dto; +package com.backendoori.ootw.post.dto.response; import java.time.LocalDateTime; -import java.util.Objects; import com.backendoori.ootw.post.domain.Post; import com.backendoori.ootw.weather.dto.TemperatureArrangeDto; import lombok.AllArgsConstructor; @@ -28,7 +27,7 @@ public static PostReadResponse from(Post post) { WriterDto.from(post.getUser()), post.getTitle(), post.getContent(), - post.getImage(), + post.getImageUrl(), post.getCreatedAt(), post.getUpdatedAt(), TemperatureArrangeDto.from(post.getTemperatureArrange()), diff --git a/src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java b/src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java similarity index 72% rename from src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java rename to src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java index 02a9fffe..004fba5d 100644 --- a/src/main/java/com/backendoori/ootw/post/dto/PostSaveResponse.java +++ b/src/main/java/com/backendoori/ootw/post/dto/response/PostSaveUpdateResponse.java @@ -1,10 +1,10 @@ -package com.backendoori.ootw.post.dto; +package com.backendoori.ootw.post.dto.response; import java.time.LocalDateTime; import com.backendoori.ootw.post.domain.Post; import com.backendoori.ootw.weather.dto.TemperatureArrangeDto; -public record PostSaveResponse( +public record PostSaveUpdateResponse( Long postId, String title, String content, @@ -14,12 +14,12 @@ public record PostSaveResponse( TemperatureArrangeDto temperatureArrange ) { - public static PostSaveResponse from(Post savedPost) { - return new PostSaveResponse( + public static PostSaveUpdateResponse from(Post savedPost) { + return new PostSaveUpdateResponse( savedPost.getId(), savedPost.getTitle(), savedPost.getContent(), - savedPost.getImage(), + savedPost.getImageUrl(), savedPost.getCreatedAt(), savedPost.getUpdatedAt(), TemperatureArrangeDto.from(savedPost.getTemperatureArrange()) diff --git a/src/main/java/com/backendoori/ootw/post/dto/WriterDto.java b/src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java similarity index 85% rename from src/main/java/com/backendoori/ootw/post/dto/WriterDto.java rename to src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java index 662f5c73..0c2b08da 100644 --- a/src/main/java/com/backendoori/ootw/post/dto/WriterDto.java +++ b/src/main/java/com/backendoori/ootw/post/dto/response/WriterDto.java @@ -1,4 +1,4 @@ -package com.backendoori.ootw.post.dto; +package com.backendoori.ootw.post.dto.response; import com.backendoori.ootw.user.domain.User; diff --git a/src/main/java/com/backendoori/ootw/post/service/PostService.java b/src/main/java/com/backendoori/ootw/post/service/PostService.java index b1830462..b16e643c 100644 --- a/src/main/java/com/backendoori/ootw/post/service/PostService.java +++ b/src/main/java/com/backendoori/ootw/post/service/PostService.java @@ -1,20 +1,24 @@ package com.backendoori.ootw.post.service; +import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST; import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; import com.backendoori.ootw.common.image.ImageFile; import com.backendoori.ootw.common.image.ImageService; import com.backendoori.ootw.common.image.exception.SaveException; +import com.backendoori.ootw.exception.PermissionException; import com.backendoori.ootw.exception.UserNotFoundException; import com.backendoori.ootw.like.domain.Like; import com.backendoori.ootw.like.repository.LikeRepository; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostReadResponse; -import com.backendoori.ootw.post.dto.PostSaveRequest; -import com.backendoori.ootw.post.dto.PostSaveResponse; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostUpdateRequest; +import com.backendoori.ootw.post.dto.response.PostReadResponse; +import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse; import com.backendoori.ootw.post.repository.PostRepository; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.user.repository.UserRepository; @@ -24,6 +28,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; import org.springframework.web.multipart.MultipartFile; @Service @@ -38,21 +43,22 @@ public class PostService { private final LikeRepository likeRepository; @Transactional - public PostSaveResponse save(PostSaveRequest request, MultipartFile postImg) { + public PostSaveUpdateResponse save(PostSaveRequest request, MultipartFile postImg) { + Assert.isTrue(Objects.nonNull(request), () -> { + throw new IllegalArgumentException(NULL_REQUEST); + }); + User user = userRepository.findById(getUserId()) .orElseThrow(UserNotFoundException::new); TemperatureArrange temperatureArrange = weatherService.getCurrentTemperatureArrange(request.coordinate()); - if (postImg.isEmpty()) { - Post savedPost = postRepository.save(Post.from(user, request, null, temperatureArrange)); - return PostSaveResponse.from(savedPost); + if (Objects.isNull(postImg) || postImg.isEmpty()) { + return savePostWithImageUrl(user, request, null, temperatureArrange); } ImageFile imgFile = imageService.upload(postImg); try { - Post savedPost = postRepository.save(Post.from(user, request, imgFile.url(), temperatureArrange)); - - return PostSaveResponse.from(savedPost); + return savePostWithImageUrl(user, request, imgFile.url(), temperatureArrange); } catch (Exception e) { imageService.delete(imgFile.fileName()); throw new SaveException(); @@ -104,6 +110,57 @@ public List getAll() { }).toList(); } + @Transactional + public void delete(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND)); + + checkUserHasPostPermission(post); + + postRepository.delete(post); + } + + @Transactional + public PostSaveUpdateResponse update(Long postId, MultipartFile postImg, PostUpdateRequest request) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NoSuchElementException(POST_NOT_FOUND)); + + checkUserHasPostPermission(post); + + Assert.notNull(request, () -> { + throw new IllegalArgumentException(NULL_REQUEST); + }); + + post.updateTitle(request.title()); + post.updateContent(request.content()); + + if (Objects.isNull(postImg) || postImg.isEmpty()) { + return updatePostWithImageUrl(post, null); + } + + ImageFile imgFile = imageService.upload(postImg); + try { + // TODO: 기존 저장된 이미지 삭제(원래 null인 경우도 있으니 주의) + return updatePostWithImageUrl(post, imgFile.url()); + } catch (Exception e) { + imageService.delete(imgFile.fileName()); + throw new SaveException(); + } + } + + private PostSaveUpdateResponse updatePostWithImageUrl(Post post, String imgFile) { + post.updateImageUrl(imgFile); + + return PostSaveUpdateResponse.from(post); + } + + private PostSaveUpdateResponse savePostWithImageUrl(User user, PostSaveRequest request, String imgFile, + TemperatureArrange temperatureArrange) { + Post savedPost = postRepository.save(Post.from(user, request, imgFile, temperatureArrange)); + + return PostSaveUpdateResponse.from(savedPost); + } + private List getLikedPostId(long userId) { return likeRepository.findByUserAndIsLike(userId, true) .stream().map(like -> like.getPost().getId()) @@ -117,12 +174,17 @@ private long getUserId() { .getPrincipal(); } - //TODO: 이 부분을 .equals 써야하는지 궁금하다. private boolean isLogin() { - return SecurityContextHolder + return !ANONYMOUS_USER_PRINCIPLE.equals(SecurityContextHolder .getContext() .getAuthentication() - .getPrincipal() != ANONYMOUS_USER_PRINCIPLE; + .getPrincipal()); + } + + private void checkUserHasPostPermission(Post post) { + Assert.isTrue(post.getUser().isSameId(getUserId()), () -> { + throw new PermissionException(); + }); } } diff --git a/src/main/java/com/backendoori/ootw/post/validation/Message.java b/src/main/java/com/backendoori/ootw/post/validation/Message.java index f5e758a9..7ed35666 100644 --- a/src/main/java/com/backendoori/ootw/post/validation/Message.java +++ b/src/main/java/com/backendoori/ootw/post/validation/Message.java @@ -7,7 +7,7 @@ public final class Message { public static final String POST_NOT_FOUND = "해당하는 게시글이 없습니다."; - public static final String NULL_POST = "게시글 생성 요청 정보가 null이어서는 안됩니다."; + public static final String NULL_REQUEST = "게시글 생성/수정 요청 정보가 null이어서는 안됩니다."; public static final String NULL_WRITER = "게시글 생성 요청 사용자가 null이어서는 안됩니다."; diff --git a/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java b/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java index 96f8a8f2..266195ea 100644 --- a/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java +++ b/src/main/java/com/backendoori/ootw/post/validation/PostValidator.java @@ -4,44 +4,45 @@ import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE; import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT; import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE; -import static com.backendoori.ootw.post.validation.Message.NULL_POST; +import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST; import static com.backendoori.ootw.post.validation.Message.NULL_TEMPERATURE_ARRANGE; import static com.backendoori.ootw.post.validation.Message.NULL_WRITER; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.common.AssertUtil; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.weather.domain.TemperatureArrange; -import org.springframework.util.Assert; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; -public class PostValidator { +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PostValidator { private static final Integer MAX_TITLE_LENGTH = 30; private static final Integer MAX_CONTENT_LENGTH = 500; public static void validateUser(User user) { - Assert.notNull(user, NULL_WRITER); + AssertUtil.notNull(user, NULL_WRITER); } public static void validatePostSaveRequest(PostSaveRequest request) { - Assert.notNull(request, NULL_POST); + AssertUtil.notNull(request, NULL_REQUEST); validateTitle(request.title()); validateContent(request.content()); } public static void validateTemperatureArrange(TemperatureArrange temperatureArrange) { - Assert.notNull(temperatureArrange, NULL_TEMPERATURE_ARRANGE); + AssertUtil.notNull(temperatureArrange, NULL_TEMPERATURE_ARRANGE); } - private static void validateTitle(String title) { - Assert.notNull(title, BLANK_POST_TITLE); - Assert.isTrue(!title.isBlank(), BLANK_POST_TITLE); - Assert.isTrue(!(title.length() > MAX_TITLE_LENGTH), INVALID_POST_TITLE); + public static void validateTitle(String title) { + AssertUtil.notBlank(title, BLANK_POST_TITLE); + AssertUtil.isTrue(!(title.length() > MAX_TITLE_LENGTH), INVALID_POST_TITLE); } - private static void validateContent(String content) { - Assert.notNull(content, BLANK_POST_CONTENT); - Assert.isTrue(!content.isBlank(), BLANK_POST_CONTENT); - Assert.isTrue(!(content.length() > MAX_CONTENT_LENGTH), INVALID_POST_CONTENT); + public static void validateContent(String content) { + AssertUtil.notBlank(content, BLANK_POST_CONTENT); + AssertUtil.isTrue(!(content.length() > MAX_CONTENT_LENGTH), INVALID_POST_CONTENT); } } diff --git a/src/main/java/com/backendoori/ootw/user/domain/User.java b/src/main/java/com/backendoori/ootw/user/domain/User.java index 1a7792b7..aa4b422f 100644 --- a/src/main/java/com/backendoori/ootw/user/domain/User.java +++ b/src/main/java/com/backendoori/ootw/user/domain/User.java @@ -1,5 +1,6 @@ package com.backendoori.ootw.user.domain; +import java.util.Objects; import com.backendoori.ootw.common.AssertUtil; import com.backendoori.ootw.common.BaseEntity; import com.backendoori.ootw.user.validation.Message; @@ -66,4 +67,8 @@ public boolean matchPassword(PasswordEncoder passwordEncoder, String decrypted) return passwordEncoder.matches(decrypted, password); } + public boolean isSameId(Long id) { + return Objects.equals(this.id, id); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 11b97e9a..1761e2b0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,6 +4,9 @@ spring: username: ${MAIN_MYSQL_USERNAME} password: ${MAIN_MYSQL_PASSWORD} url: ${MAIN_MYSQL_URL} + hikari: + maximum-pool-size: 100 + minimum-idle: 10 jpa: hibernate: ddl-auto: validate diff --git a/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java b/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java index 2bce89b4..52e2b2a6 100644 --- a/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java +++ b/src/test/java/com/backendoori/ootw/like/controller/LikeControllerTest.java @@ -14,7 +14,7 @@ import com.backendoori.ootw.like.repository.LikeRepository; import com.backendoori.ootw.post.controller.PostController; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.post.repository.PostRepository; import com.backendoori.ootw.post.service.PostService; import com.backendoori.ootw.security.TokenMockMvcTest; diff --git a/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java b/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java index f999c7ed..dd43d9fb 100644 --- a/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java +++ b/src/test/java/com/backendoori/ootw/like/domain/LikeTest.java @@ -8,7 +8,7 @@ import java.util.HashMap; import java.util.Map; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.weather.domain.TemperatureArrange; import com.backendoori.ootw.weather.domain.forecast.ForecastCategory; diff --git a/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java b/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java index 8cdf374b..90acc3c6 100644 --- a/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java +++ b/src/test/java/com/backendoori/ootw/like/service/LikeServiceTest.java @@ -17,7 +17,7 @@ import com.backendoori.ootw.like.repository.LikeRepository; import com.backendoori.ootw.post.controller.PostController; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.post.repository.PostRepository; import com.backendoori.ootw.post.service.PostService; import com.backendoori.ootw.security.TokenMockMvcTest; @@ -40,8 +40,6 @@ public class LikeServiceTest extends TokenMockMvcTest { static final String POST_NOT_FOUND_MESSAGE = "해당 게시글이 존재하지 않습니다."; static final Faker FAKER = new Faker(); - static final int NX = 55; - static final int NY = 127; User user; diff --git a/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java b/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java index 6882577d..db3ce78f 100644 --- a/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java +++ b/src/test/java/com/backendoori/ootw/post/controller/PostControllerTest.java @@ -1,11 +1,13 @@ package com.backendoori.ootw.post.controller; +import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND; import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_HEADER; import static com.backendoori.ootw.security.jwt.JwtAuthenticationFilter.TOKEN_PREFIX; import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -17,10 +19,11 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; +import com.backendoori.ootw.exception.PermissionException; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostReadResponse; -import com.backendoori.ootw.post.dto.PostSaveRequest; -import com.backendoori.ootw.post.dto.PostSaveResponse; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; +import com.backendoori.ootw.post.dto.response.PostReadResponse; +import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse; import com.backendoori.ootw.post.repository.PostRepository; import com.backendoori.ootw.post.service.PostService; import com.backendoori.ootw.security.TokenMockMvcTest; @@ -29,7 +32,9 @@ import com.backendoori.ootw.weather.domain.TemperatureArrange; import com.backendoori.ootw.weather.domain.forecast.ForecastCategory; import com.backendoori.ootw.weather.service.WeatherService; +import com.fasterxml.jackson.core.JsonProcessingException; import net.datafaker.Faker; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -48,11 +53,23 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.test.context.TestSecurityContextHolder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; @TestInstance(Lifecycle.PER_CLASS) class PostControllerTest extends TokenMockMvcTest { static final Faker FAKER = new Faker(); + public static final String IMG_URL = "imageUrl"; + public static final String BASE_URL = "http://localhost:8080/api/v1/posts"; + public static final String ORIGINAL_FILE_NAME = "filename.jpeg"; + public static final String FILE_NAME = "postImg"; + public static final String CONTENT = "CONTENT"; + public static final String TITLE = "TITLE"; + + @NotNull + private static MockMultipartFile getPostImg(String originalFileName, String mediaType) { + return new MockMultipartFile(FILE_NAME, originalFileName, mediaType, "some xml".getBytes()); + } User user; @@ -97,28 +114,412 @@ private User generateUser() { .build(); } + @NotNull + private MockMultipartFile getRequestJson(String title, String content) throws JsonProcessingException { + PostSaveRequest postSaveRequest = + new PostSaveRequest(title, content, VALID_COORDINATE); + + return new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(postSaveRequest)); + } + + @NotNull + private static RequestPostProcessor makeRequestMethodToPut() { + return req -> { + req.setMethod("PUT"); + return req; + }; + } + @Nested - @DisplayName("게시글 저장 테스트") - class SaveTest { + @DisplayName("게시글 삭제하기") + class DeleteTest { + + Post userPost; + Post otherPost; + + @BeforeEach + void setup() { + userPost = postRepository.save( + Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null, + generateTemperatureArrange())); + + User other = userRepository.save(generateUser()); + otherPost = postRepository.save( + Post.from(other, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null, + generateTemperatureArrange())); + } + + @Test + @DisplayName("게시글 삭제에 성공한다.") + void deleteSuccess() throws Exception { + // given // when + MockHttpServletRequestBuilder requestBuilder = + delete(BASE_URL + "/" + userPost.getId()) + .header(TOKEN_HEADER, TOKEN_PREFIX + token); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isNoContent()) + .andReturn(); + } + + @Test + @DisplayName("로그인을 안한 사용자는 게시글 삭제에 접근이 불가하다.") + void deleteFaildeleteFailWithUnauthorizedUser() throws Exception { + // given // when + MockHttpServletRequestBuilder requestBuilder = + delete(BASE_URL + "/" + userPost.getId()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isUnauthorized()) + .andReturn(); + } + + @Test + @DisplayName("게시글 주인이 아닌 사용자가 게시글 삭제에 실패한다.") + void deleteFailWithNoPermission() throws Exception { + // given // when + MockHttpServletRequestBuilder requestBuilder = + delete(BASE_URL + "/" + otherPost.getId()) + .header(TOKEN_HEADER, TOKEN_PREFIX + token); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message", is(PermissionException.DEFAULT_MESSAGE))) + .andReturn(); + } @Test + @DisplayName("존재하지 않는 게시글 삭제에 실패한다.") + void deleteFailWithNonExistPost() throws Exception { + // given // when + MockHttpServletRequestBuilder requestBuilder = + delete(BASE_URL + "/" + otherPost.getId() + 1) + .header(TOKEN_HEADER, TOKEN_PREFIX + token); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message", is(POST_NOT_FOUND))) + .andReturn(); + } + + } + + @Nested + @DisplayName("게시글 수정하기") + class UpdateTest { + + Post userPost; + Post otherPost; + + @BeforeEach + void setup() { + userPost = postRepository.save( + Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null, + generateTemperatureArrange())); + + User other = userRepository.save(generateUser()); + otherPost = postRepository.save( + Post.from(other, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), null, + generateTemperatureArrange())); + } + + @Nested + @DisplayName("게시글 수정에 성공한다") + class UpdateSuccess { + + static Stream provideImageTypes() { + return Stream.of( + Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE), + Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE), + Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE) + ); + } + + @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.") + @MethodSource("provideImageTypes") + @DisplayName(" 게시글 정보와 이미지 수정에 성공한다.") + void updateAllSuccess(String originalFileName, String mediaType) throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + MockHttpServletResponse response = mockMvc.perform(requestBuilder) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + .getResponse(); + + assertThat(response.getHeader("location")).contains("/api/v1/posts/"); + } + + @Test + @DisplayName("게시글 정보 수정에 성공한다.") + void updatePostUpdateRequestSuccess() throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(request) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + MockHttpServletResponse response = mockMvc.perform(requestBuilder) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + .getResponse(); + + assertThat(response.getHeader("location")).contains("/api/v1/posts/"); + } + + } + + @Nested + @DisplayName("게시글 수정에 실패한다") + class UpdateFail { + + static Stream provideInvalidPostInfo() { + return Stream.of( + Arguments.of(null, CONTENT), + Arguments.of(TITLE, null), + Arguments.of("", CONTENT), + Arguments.of(TITLE, ""), + Arguments.of(" ", CONTENT), + Arguments.of(TITLE, " "), + Arguments.of("a".repeat(40), CONTENT), + Arguments.of(TITLE, "a".repeat(600)) + ); + } + + static Stream provideInvalidFile() { + return Stream.of( + Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE), + Arguments.of("file.html", MediaType.TEXT_HTML_VALUE), + Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE), + Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE) + ); + } + + @Test + @DisplayName("로그인을 안한 사용자는 게시글 수정에 접근이 불가하다.") + void updateFailWithUnauthorizedUser() throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(request) + .file(postImg) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isUnauthorized()) + .andReturn(); + } + + @Test + @DisplayName("게시글 주인이 아닌 사용자가 게시글 수정에 실패한다.") + void updateFailWithPermission() throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + otherPost.getId()) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message", is(PermissionException.DEFAULT_MESSAGE))) + .andReturn(); + } + + @Test + @DisplayName("존재하지 않는 게시글 수정에 실패한다.") + void updateFailWithNonExistPost() throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + otherPost.getId() + 1) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message", is(POST_NOT_FOUND))) + .andReturn(); + } + + @Test + @DisplayName("수정할 리소스를 전혀 보내지 않으면 실패한다.") + void updateFailWithNoResource() throws Exception { + // given // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + @Test + @DisplayName("수정할 이미지만 보내면 수정에 실패한다.") + void updateFailWithNullImage() throws Exception { + // given + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우") + @MethodSource("provideInvalidPostInfo") + @DisplayName("수정할 게시글 정보를 보냈는데 유효하지 않으면 수정에 실패한다.") + void updateFailWithInvalidPostUpdateRequest(String title, String content) throws Exception { + // given + MockMultipartFile request = getRequestJson(title, content); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우") + @MethodSource("provideInvalidFile") + @DisplayName("수정할 게시글 정보와 파일을 보냈는데 파일이 유효하지 않으면 수정에 실패한다.") + void updateFailWithInvalidFileType(String originalFileName, String mediaType) throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL + "/" + userPost.getId()) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .characterEncoding(StandardCharsets.UTF_8) + .with(makeRequestMethodToPut()); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + } + + } + + @Nested + @DisplayName("게시글 저장하기") + class SaveTest { + + static Stream provideInvalidPostInfo() { + return Stream.of( + Arguments.of(null, CONTENT), + Arguments.of(PostControllerTest.TITLE, null), + Arguments.of("", CONTENT), + Arguments.of(PostControllerTest.TITLE, ""), + Arguments.of(" ", CONTENT), + Arguments.of(PostControllerTest.TITLE, " "), + Arguments.of("a".repeat(40), CONTENT), + Arguments.of(PostControllerTest.TITLE, "a".repeat(600)) + ); + } + + static Stream provideImageTypes() { + return Stream.of( + Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE), + Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE), + Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE) + ); + } + + static Stream provideInvalidFile() { + return Stream.of( + Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE), + Arguments.of("file.html", MediaType.TEXT_HTML_VALUE), + Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE), + Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE) + ); + } + + @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.") + @MethodSource("provideImageTypes") @DisplayName("게시글 저장에 성공한다.") - void saveSuccess() throws Exception { + void saveSuccess(String originalFileName, String mediaType) throws Exception { // given given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)) .willReturn(generateTemperatureArrange()); - PostSaveRequest postSaveRequest = - new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE); - MockMultipartFile request = - new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(postSaveRequest)); - MockMultipartFile postImg = - new MockMultipartFile("postImg", "filename.jpeg", MediaType.IMAGE_JPEG_VALUE, - "some xml".getBytes()); + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); // when - MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts") + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL) .file(request) .file(postImg) .header(TOKEN_HEADER, TOKEN_PREFIX + token) @@ -136,24 +537,20 @@ void saveSuccess() throws Exception { assertThat(response.getHeader("location")).contains("/api/v1/posts/"); } - @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우") - @MethodSource("provideInvalidPostInfo") - @DisplayName("유효하지 않은 요청 값이 포함된 게시글 저장에 실패한다.") - void saveFailByMethodArgumentNotValidException(String title, String content) throws Exception { + @Test + @DisplayName("저장되지 않은 유저가 포함된 게시글 저장에 실패한다.") + void saveFailNonSavedUser() throws Exception { // given + setToken(user.getId() + 1); + given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)) .willReturn(generateTemperatureArrange()); - PostSaveRequest postSaveRequest = new PostSaveRequest(title, content, VALID_COORDINATE); - MockMultipartFile request = - new MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, - objectMapper.writeValueAsBytes(postSaveRequest)); - MockMultipartFile postImg = - new MockMultipartFile("postImg", "filename.txt", MediaType.MULTIPART_FORM_DATA_VALUE, - "some xml".getBytes()); + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); // when - MockHttpServletRequestBuilder requestBuilder = multipart("http://localhost:8080/api/v1/posts") + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL) .file(request) .file(postImg) .header(TOKEN_HEADER, TOKEN_PREFIX + token) @@ -162,45 +559,71 @@ void saveFailByMethodArgumentNotValidException(String title, String content) thr // then mockMvc.perform(requestBuilder) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message", instanceOf(String.class))) + .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } + @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우") + @MethodSource("provideInvalidPostInfo") + @DisplayName("유효하지 않은 요청 값이 포함된 게시글 저장에 실패한다.") + void saveFailByInvalidPostSaveRequest(String title, String content) throws Exception { + // given + MockMultipartFile request = getRequestJson(title, content); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); - static Stream provideInvalidPostInfo() { - String validTitle = "title"; - String validContent = "content"; - return Stream.of( - Arguments.of(null, validContent), - Arguments.of(validTitle, null), - Arguments.of("", validContent), - Arguments.of(validTitle, ""), - Arguments.of(" ", validContent), - Arguments.of(validTitle, " "), - Arguments.of("a".repeat(40), validContent), - Arguments.of(validTitle, "a".repeat(600)) - ); + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } + @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우") + @MethodSource("provideInvalidFile") + @DisplayName("게시글 정보와 파일을 보냈는데 파일이 유효하지 않으면 저장에 실패한다.") + void saveFailByInvalidFileType(String originalFileName, String mediaType) throws Exception { + // given + MockMultipartFile request = getRequestJson(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + + // when + MockHttpServletRequestBuilder requestBuilder = multipart(BASE_URL) + .file(request) + .file(postImg) + .header(TOKEN_HEADER, TOKEN_PREFIX + token) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON); + + // then + mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } } @Nested @DisplayName("게시글 단건 조회하기") - class GetDetailByPostId { + class GetDetailByPostIdTest { private static final String URL = "http://localhost:8080/api/v1/posts/"; - PostSaveResponse postSaveResponse; + PostSaveUpdateResponse postSaveResponse; @BeforeEach void setUp() { TestSecurityContextHolder.setAuthentication(new TestingAuthenticationToken(user.getId(), null)); Post savedPost = postRepository.save( - Post.from(user, new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE), "imgUrl", + Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL, generateTemperatureArrange())); - postSaveResponse = PostSaveResponse.from(savedPost); + postSaveResponse = PostSaveUpdateResponse.from(savedPost); } @Test @@ -237,7 +660,7 @@ void getDetailByPostIdSuccess() throws Exception { @Nested @DisplayName("게시글 목록 조회하기") - class GetAll { + class GetAllTest { static final Integer SAVE_COUNT = 10; @@ -246,8 +669,7 @@ void setUp() { TestSecurityContextHolder.setAuthentication(new TestingAuthenticationToken(user.getId(), null)); for (int i = 0; i < SAVE_COUNT; i++) { - Post savedPost = postRepository.save( - Post.from(user, new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE), "imgUrl", + postRepository.save(Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL, generateTemperatureArrange())); } } @@ -256,7 +678,7 @@ void setUp() { @DisplayName("게시글 목록 조회에 성공한다.") void getAllSuccess() throws Exception { // given // when - MockHttpServletRequestBuilder requestBuilder = get("http://localhost:8080/api/v1/posts") + MockHttpServletRequestBuilder requestBuilder = get(BASE_URL) .header(TOKEN_HEADER, TOKEN_PREFIX + token) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON); @@ -276,6 +698,7 @@ void getAllSuccess() throws Exception { } + private TemperatureArrange generateTemperatureArrange() { Map weatherInfoMap = new HashMap<>(); weatherInfoMap.put(ForecastCategory.TMN, String.valueOf(0.0)); diff --git a/src/test/java/com/backendoori/ootw/post/domain/PostTest.java b/src/test/java/com/backendoori/ootw/post/domain/PostTest.java index 53b6b1ee..e41a104b 100644 --- a/src/test/java/com/backendoori/ootw/post/domain/PostTest.java +++ b/src/test/java/com/backendoori/ootw/post/domain/PostTest.java @@ -15,7 +15,7 @@ import java.util.HashMap; import java.util.Map; import java.util.stream.Stream; -import com.backendoori.ootw.post.dto.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.weather.domain.TemperatureArrange; import com.backendoori.ootw.weather.domain.forecast.ForecastCategory; @@ -77,7 +77,7 @@ void createPostSuccess() { assertAll(() -> assertThat(createdPost).hasFieldOrPropertyWithValue("user", MOCK_USER), () -> assertThat(createdPost).hasFieldOrPropertyWithValue("title", request.title()), () -> assertThat(createdPost).hasFieldOrPropertyWithValue("content", request.content()), - () -> assertThat(createdPost).hasFieldOrPropertyWithValue("image", IMG_URL), + () -> assertThat(createdPost).hasFieldOrPropertyWithValue("imageUrl", IMG_URL), () -> assertThat(createdPost).hasFieldOrPropertyWithValue("temperatureArrange", generateTemperatureArrange())); } diff --git a/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java b/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java index 07c72c9b..59f913a5 100644 --- a/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java +++ b/src/test/java/com/backendoori/ootw/post/service/PostServiceTest.java @@ -1,14 +1,19 @@ package com.backendoori.ootw.post.service; +import static com.backendoori.ootw.post.validation.Message.BLANK_POST_CONTENT; +import static com.backendoori.ootw.post.validation.Message.BLANK_POST_TITLE; +import static com.backendoori.ootw.post.validation.Message.INVALID_POST_CONTENT; +import static com.backendoori.ootw.post.validation.Message.INVALID_POST_TITLE; +import static com.backendoori.ootw.post.validation.Message.NULL_REQUEST; import static com.backendoori.ootw.post.validation.Message.POST_NOT_FOUND; import static com.backendoori.ootw.util.provider.ForecastApiCommonRequestSourceProvider.VALID_COORDINATE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.BDDMockito.given; -import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -18,14 +23,16 @@ import com.backendoori.ootw.common.image.ImageFile; import com.backendoori.ootw.common.image.ImageService; import com.backendoori.ootw.common.image.exception.SaveException; +import com.backendoori.ootw.exception.PermissionException; import com.backendoori.ootw.exception.UserNotFoundException; import com.backendoori.ootw.like.repository.LikeRepository; import com.backendoori.ootw.like.service.LikeService; import com.backendoori.ootw.post.domain.Post; -import com.backendoori.ootw.post.dto.PostReadResponse; -import com.backendoori.ootw.post.dto.PostSaveRequest; -import com.backendoori.ootw.post.dto.PostSaveResponse; -import com.backendoori.ootw.post.dto.WriterDto; +import com.backendoori.ootw.post.dto.request.PostSaveRequest; +import com.backendoori.ootw.post.dto.request.PostUpdateRequest; +import com.backendoori.ootw.post.dto.response.PostReadResponse; +import com.backendoori.ootw.post.dto.response.PostSaveUpdateResponse; +import com.backendoori.ootw.post.dto.response.WriterDto; import com.backendoori.ootw.post.repository.PostRepository; import com.backendoori.ootw.user.domain.User; import com.backendoori.ootw.user.repository.UserRepository; @@ -35,6 +42,8 @@ import com.backendoori.ootw.weather.service.WeatherService; import net.datafaker.Faker; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -46,9 +55,11 @@ 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.NullSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.TestingAuthenticationToken; @@ -61,6 +72,11 @@ class PostServiceTest { static final Faker FAKER = new Faker(); + public static final String IMG_URL = "http://mock.server.com/filename.jpeg"; + public static final String ORIGINAL_FILE_NAME = "filename.jpeg"; + public static final String FILE_NAME = "filename"; + public static final String TITLE = "TITLE"; + public static final String CONTENT = "CONTENT"; User user; @@ -102,25 +118,288 @@ void cleanup() { userRepository.deleteAll(); } + @Nested - @DisplayName("게시글 저장 테스트") - class SaveTest { + @DisplayName("게시글 삭제하기") + class DeleteTest { + + Post userPost; + Post otherPost; + + @BeforeEach + void setup() { + userPost = postRepository.save( + Post.from(user, new PostSaveRequest("title", "content", VALID_COORDINATE), null, + generateTemperatureArrange())); + + User other = userRepository.save(generateUser()); + otherPost = postRepository.save( + Post.from(other, new PostSaveRequest("title", "content", VALID_COORDINATE), null, + generateTemperatureArrange())); + } + + @Test + @DisplayName("게시글 삭제에 성공한다.") + void deleteSuccess() { + // given // when // then + assertDoesNotThrow(() -> postService.delete(userPost.getId())); + } @Test + @DisplayName("게시글 주인이 아닌 사용자가 게시글 삭제에 실패한다.") + void deleteFailWithNoPermission() { + // given // when + ThrowingCallable deletePost = () -> postService.delete(otherPost.getId()); + + // then + assertThatExceptionOfType(PermissionException.class) + .isThrownBy(deletePost) + .withMessage(PermissionException.DEFAULT_MESSAGE); + } + + @Test + @DisplayName("존재하지 않는 게시글 삭제에 실패한다.") + void deleteFailWithNonExistPost() { + // given // when + ThrowingCallable deletePost = () -> postService.delete(otherPost.getId() + 1); + + // then + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(deletePost) + .withMessage(POST_NOT_FOUND); + } + + } + + @NotNull + private static MockMultipartFile getPostImg(String originalFileName, String mediaType) { + return new MockMultipartFile(FILE_NAME, originalFileName, mediaType, "some xml".getBytes()); + } + + @Nested + @DisplayName("게시글 수정하기") + class UpdateTest { + + Post userPost; + Post otherPost; + + @BeforeEach + void setup() { + userPost = postRepository.save( + Post.from(user, new PostSaveRequest("title", "content", VALID_COORDINATE), null, + generateTemperatureArrange())); + + User other = userRepository.save(generateUser()); + otherPost = postRepository.save( + Post.from(other, new PostSaveRequest("title", "content", VALID_COORDINATE), null, + generateTemperatureArrange())); + } + + @Nested + @DisplayName("게시글 수정에 성공한다") + class UpdateSuccess { + + static Stream provideImageTypes() { + return Stream.of( + Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE), + Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE), + Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE) + ); + } + + @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.") + @MethodSource("provideImageTypes") + @DisplayName(" 게시글 정보와 이미지 수정에 성공한다.") + void updateAllSuccess(String originalFileName, String mediaType) { + // given + PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + + given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME)); + + // when + PostSaveUpdateResponse response = postService.update(userPost.getId(), postImg, request); + + //then + assertAll( + () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()), + () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content()), + () -> assertThat(response).hasFieldOrPropertyWithValue("image", IMG_URL) + ); + } + + @Test + @DisplayName("게시글 정보만 수정에 성공한다.") + void updatePostUpdateRequestSuccess() { + // given + PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT); + + // when + PostSaveUpdateResponse response = postService.update(userPost.getId(), null, request); + + //then + assertAll( + () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()), + () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content()) + ); + } + + } + + @Nested + @DisplayName("게시글 수정에 실패한다") + class UpdateFail { + + static Stream provideInvalidFile() { + return Stream.of( + Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE), + Arguments.of("file.html", MediaType.TEXT_HTML_VALUE), + Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE), + Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE) + ); + } + + static Stream provideInvalidPostUpdateRequest() { + return Stream.of( + Arguments.of("제목이 null인 경우", new PostUpdateRequest(null, "content"), BLANK_POST_TITLE), + Arguments.of("제목이 공백인 경우", new PostUpdateRequest(" ", "content"), BLANK_POST_TITLE), + Arguments.of("제목이 30자가 넘는 경우", new PostUpdateRequest("t".repeat(31), "content"), + INVALID_POST_TITLE), + Arguments.of("내용이 null인 경우", new PostUpdateRequest("title", null), BLANK_POST_CONTENT), + Arguments.of("내용이 공백인 경우", new PostUpdateRequest("title", " "), BLANK_POST_CONTENT), + Arguments.of("내용이 500자가 넘는 경우", new PostUpdateRequest("title", "t".repeat(501)), + INVALID_POST_CONTENT), + Arguments.of("제목과 내용이 모두 null인 경우", new PostUpdateRequest(null, null), BLANK_POST_TITLE), + Arguments.of("제목과 내용이 모두 공백인 경우", new PostUpdateRequest(" ", " "), BLANK_POST_TITLE) + ); + } + + @Test + @DisplayName("게시글 주인이 아닌 사용자가 게시글 수정에 실패한다.") + void updateFailWithPermission() { + // given + PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + ThrowingCallable updatePost = () -> postService.update(otherPost.getId(), postImg, request); + + //then + assertThatExceptionOfType(PermissionException.class) + .isThrownBy(updatePost) + .withMessage(PermissionException.DEFAULT_MESSAGE); + } + + @Test + @DisplayName("존재하지 않는 게시글 수정에 실패한다.") + void updateFailWithNonExistPost() { + // given + PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + // when + ThrowingCallable updatePost = () -> postService.update(otherPost.getId() + 1, postImg, request); + + //then + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(updatePost) + .withMessage(POST_NOT_FOUND); + } + + @Test + @DisplayName("수정할 리소스를 전혀 보내지 않으면 실패한다.") + void updateFailWithNoResource() { + // given // when + ThrowingCallable updatePost = () -> postService.update(userPost.getId(), null, null); + + //then + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(updatePost) + .withMessage(NULL_REQUEST); + } + + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("provideInvalidPostUpdateRequest") + @DisplayName("수정할 게시글 정보를 보냈는데 제목이나 내용이 유효하지 않으면 수정에 실패한다.") + void updateFailWithInvalidPostUpdateRequest(String testCase, PostUpdateRequest request, String message) { + // given // when + ThrowingCallable updatePost = () -> postService.update(userPost.getId(), null, request); + + //then + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(updatePost) + .withMessage(message); + } + + @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우") + @MethodSource("provideInvalidFile") + @DisplayName("수정할 이미지를 보냈는데 이미지 파일이 유효하지 않으면 수정에 실패한다.") + void updateFailWithInvalidFileType(String originalFileName, String mediaType) { + // given + PostUpdateRequest request = new PostUpdateRequest(TITLE, CONTENT); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + given(imageService.upload(postImg)).willThrow(IllegalArgumentException.class); + + // when + ThrowingCallable updatePost = () -> postService.update(userPost.getId(), postImg, request); + + //then + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(updatePost); + } + + } + + } + + @Nested + @DisplayName("게시글 저장하기") + class SaveTest { + + static Stream provideImageTypes() { + return Stream.of( + Arguments.of("image.jpeg", MediaType.IMAGE_JPEG_VALUE), + Arguments.of("image.gif", MediaType.IMAGE_GIF_VALUE), + Arguments.of("image.png", MediaType.IMAGE_PNG_VALUE) + ); + } + + static Stream provideInvalidFile() { + return Stream.of( + Arguments.of("file.md", MediaType.TEXT_MARKDOWN_VALUE), + Arguments.of("file.html", MediaType.TEXT_HTML_VALUE), + Arguments.of("file.pdf", MediaType.APPLICATION_PDF_VALUE), + Arguments.of("file.txt", MediaType.TEXT_PLAIN_VALUE) + ); + } + + static Stream provideInvalidPostInfo() { + return Stream.of( + Arguments.of(new PostSaveRequest(null, CONTENT, VALID_COORDINATE)), + Arguments.of(new PostSaveRequest(TITLE, null, VALID_COORDINATE)), + Arguments.of(new PostSaveRequest("", CONTENT, VALID_COORDINATE)), + Arguments.of(new PostSaveRequest(TITLE, "", VALID_COORDINATE)), + Arguments.of(new PostSaveRequest(" ", CONTENT, VALID_COORDINATE)), + Arguments.of(new PostSaveRequest(TITLE, " ", VALID_COORDINATE)), + Arguments.of(new PostSaveRequest("a".repeat(40), CONTENT, VALID_COORDINATE)), + Arguments.of(new PostSaveRequest(TITLE, "a".repeat(600), VALID_COORDINATE)) + ); + } + + @ParameterizedTest(name = "[{index}]: 아이템 타입이 {0}인 경우에 저장에 성공한다.") + @MethodSource("provideImageTypes") @DisplayName("게시글 저장에 성공한다.") - void saveSuccess() throws IOException { + void saveSuccess(String originalFileName, String mediaType) { // given - PostSaveRequest request = new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE); - MockMultipartFile postImg = new MockMultipartFile("file", "filename.jpeg", - "image/jpeg", "some xml".getBytes()); + PostSaveRequest request = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); - given(imageService.upload(postImg)).willReturn( - new ImageFile("http://mock.server.com/filename.jpeg", "filename.jpeg")); + given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME)); given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn( generateTemperatureArrange()); // when - PostSaveResponse postSaveResponse = postService.save(request, postImg); + PostSaveUpdateResponse postSaveResponse = postService.save(request, postImg); //then assertThat(postSaveResponse).hasFieldOrPropertyWithValue("title", request.title()); @@ -131,15 +410,33 @@ void saveSuccess() throws IOException { TemperatureArrangeDto.from(generateTemperatureArrange())); } + @Test + @DisplayName("게시글 정보만 저장에 성공한다.") + void updatePostUpdateRequestSuccess() { + // given + PostSaveRequest request = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE); + + given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn( + generateTemperatureArrange()); + + // when + PostSaveUpdateResponse response = postService.save(request, null); + + //then + assertAll( + () -> assertThat(response).hasFieldOrPropertyWithValue("title", request.title()), + () -> assertThat(response).hasFieldOrPropertyWithValue("content", request.content()) + ); + } + @Test @DisplayName("저장된 유저가 아닌 경우 게시글 저장에 실패한다.") void saveFailUserNotFound() { // given setAuthentication(user.getId() + 1); - PostSaveRequest postSaveRequest = new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE); - MockMultipartFile postImg = new MockMultipartFile("file", "filename.txt", - "text/plain", "some xml".getBytes()); + PostSaveRequest postSaveRequest = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE); + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); // when ThrowingCallable savePost = () -> postService.save(postSaveRequest, postImg); @@ -150,37 +447,52 @@ void saveFailUserNotFound() { .withMessage(UserNotFoundException.DEFAULT_MESSAGE); } - static Stream provideInvalidPostInfo() { - String validTitle = "title"; - String validContent = "content"; - return Stream.of( - Arguments.of(null, validContent), - Arguments.of(validTitle, null), - Arguments.of("", validContent), - Arguments.of(validTitle, ""), - Arguments.of(" ", validContent), - Arguments.of(validTitle, " "), - Arguments.of("a".repeat(40), validContent), - Arguments.of(validTitle, "a".repeat(600)) - ); - } - @ParameterizedTest(name = "[{index}] 제목이 {0}이고 내용이 {1}인 경우") @MethodSource("provideInvalidPostInfo") @DisplayName("유효하지 않은 값(게시글 정보)가 들어갈 경우 게시글 저장에 실패한다.") - void saveFailWithInvalidValue(String title, String content) { + void saveFailWithInvalidValue(@Nullable PostSaveRequest request) { // given + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME)); given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn( generateTemperatureArrange()); - PostSaveRequest postSaveRequest = new PostSaveRequest(title, content, VALID_COORDINATE); - MockMultipartFile postImg = new MockMultipartFile("file", "filename.jpeg", - "image/jpeg", "some xml".getBytes()); - given(imageService.upload(postImg)).willReturn( - new ImageFile("http://mock.server.com/filename.jpeg", "filename.jpeg")); - // when, then assertThrows(SaveException.class, + () -> postService.save(request, postImg)); + } + + @ParameterizedTest + @NullSource + @DisplayName("게시글 정보가 null로 들어갈 경우 게시글 저장에 실패한다.") + void saveFailWithNullPostSaveRequest(PostSaveRequest request) { + // given + MockMultipartFile postImg = getPostImg(ORIGINAL_FILE_NAME, MediaType.IMAGE_JPEG_VALUE); + + given(imageService.upload(postImg)).willReturn(new ImageFile(IMG_URL, ORIGINAL_FILE_NAME)); + given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn( + generateTemperatureArrange()); + + // when, then + assertThrows(IllegalArgumentException.class, + () -> postService.save(request, postImg)); + } + + @ParameterizedTest(name = "[{index}] 파일 타입이 {1}인 경우") + @MethodSource("provideInvalidFile") + @DisplayName("수정할 이미지를 보냈는데 이미지 파일이 유효하지 않으면 수정에 실패한다.") + void updateFailWithInvalidFileType(String originalFileName, String mediaType) { + // given + given(weatherService.getCurrentTemperatureArrange(VALID_COORDINATE)).willReturn( + generateTemperatureArrange()); + + PostSaveRequest postSaveRequest = new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE); + MockMultipartFile postImg = getPostImg(originalFileName, mediaType); + given(imageService.upload(postImg)).willThrow(IllegalArgumentException.class); + + // when, then + assertThrows(IllegalArgumentException.class, () -> postService.save(postSaveRequest, postImg)); } @@ -188,16 +500,16 @@ void saveFailWithInvalidValue(String title, String content) { @Nested @DisplayName("게시글 단건 조회하기") - class GetDetailByPostId { + class GetDetailByPostIdTest { - PostSaveResponse postSaveResponse; + PostSaveUpdateResponse postSaveResponse; @BeforeEach void setUp() { Post savedPost = postRepository.save( - Post.from(user, new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE), "imgUrl", + Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL, generateTemperatureArrange())); - postSaveResponse = PostSaveResponse.from(savedPost); + postSaveResponse = PostSaveUpdateResponse.from(savedPost); } @Test @@ -285,12 +597,11 @@ void getAllSuccessWithoutLogin() { } - } @Nested @DisplayName("게시글 목록 조회하기") - class GetAll { + class GetAllTest { static final Integer SAVE_COUNT = 10; @@ -298,7 +609,7 @@ class GetAll { void setUp() { for (int i = 0; i < SAVE_COUNT; i++) { postRepository.save( - Post.from(user, new PostSaveRequest("Test Title", "Test Content", VALID_COORDINATE), "imgUrl", + Post.from(user, new PostSaveRequest(TITLE, CONTENT, VALID_COORDINATE), IMG_URL, generateTemperatureArrange())); } } diff --git a/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java b/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java index c1ae9abe..7fa50e81 100644 --- a/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java +++ b/src/test/java/com/backendoori/ootw/weather/domain/TemperatureArrangeTest.java @@ -1,5 +1,6 @@ package com.backendoori.ootw.weather.domain; +import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE; import static com.backendoori.ootw.weather.validation.Message.CAN_NOT_USE_FORECAST_API; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -56,7 +57,7 @@ void createTemperatureArrangeSuccess() { @ParameterizedTest @MethodSource("provideInvalidWeatherInfoMap") @DisplayName("TMN, TMX가 포함되지 않은 결과 맵(map)으로부터 TemperatureArrange 생성에 실패한다.") - void createTemperatureArrangeFail(Map weatherInfoMap) { + void createTemperatureArrangeFailWithInvalidValue(Map weatherInfoMap) { // given // when ThrowingCallable createTemperatureArrange = () -> TemperatureArrange.from(weatherInfoMap); @@ -66,4 +67,21 @@ void createTemperatureArrangeFail(Map weatherInfoMap) .withMessage(CAN_NOT_USE_FORECAST_API); } + @Test + @DisplayName("유효하지 않은 TMN, TMX가 포함된 결과 맵(map)으로부터 TemperatureArrange 생성에 실패한다.") + void createTemperatureArrangeFailWithInvalidArrange() { + // given + Map weatherInfoMap = new HashMap<>(); + weatherInfoMap.put(ForecastCategory.TMN, "10.0"); + weatherInfoMap.put(ForecastCategory.TMX, "0.0"); + + // when + ThrowingCallable createTemperatureArrange = () -> TemperatureArrange.from(weatherInfoMap); + + // then + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(createTemperatureArrange) + .withMessage(CAN_NOT_RETRIEVE_TEMPERATURE_ARRANGE); + } + }