diff --git a/backend/src/main/java/com/votogether/domain/member/entity/Member.java b/backend/src/main/java/com/votogether/domain/member/entity/Member.java index e9360903b..6b322287b 100644 --- a/backend/src/main/java/com/votogether/domain/member/entity/Member.java +++ b/backend/src/main/java/com/votogether/domain/member/entity/Member.java @@ -23,15 +23,13 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.ToString; import org.apache.commons.lang3.RandomStringUtils; -@Table(indexes = {@Index(columnList = "socialId, socialType")}) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode(of = {"id"}) -@ToString @Getter @Entity +@EqualsAndHashCode(of = {"id"}, callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = {@Index(columnList = "socialId, socialType")}) public class Member extends BaseEntity { private static final String INITIAL_NICKNAME_PREFIX = "익명의손님"; @@ -47,7 +45,6 @@ public class Member extends BaseEntity { @Column(length = 20) private Gender gender; - @Column private Integer birthYear; @Enumerated(value = EnumType.STRING) diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java index 7e5e80846..b8dbc3efd 100644 --- a/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java +++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/AgeRange.java @@ -8,38 +8,28 @@ @Getter public enum AgeRange { - UNDER_TEENS("10대 미만", 1, 9), - TEENS("10대", 10, 19), - TWENTIES("20대", 20, 29), - THIRTIES("30대", 30, 39), - FORTIES("40대", 40, 49), - FIFTIES("50대", 50, 59), - OVER_SIXTIES("60대 이상", 60, 999), + UNDER_TEENS("10대 미만", 0), + TEENS("10대", 1), + TWENTIES("20대", 2), + THIRTIES("30대", 3), + FORTIES("40대", 4), + FIFTIES("50대", 5), + OVER_SIXTIES("60대 이상", 6), ; private final String name; - private final int startAge; - private final int endAge; + private final int ageGroup; - AgeRange( - final String name, - final int startAge, - final int endAge - ) { + AgeRange(final String name, final int ageGroup) { this.name = name; - this.startAge = startAge; - this.endAge = endAge; + this.ageGroup = ageGroup; } - public static AgeRange from(final int age) { + public static AgeRange from(final int ageGroup) { return Arrays.stream(AgeRange.values()) - .filter(ageRange -> ageRange.isBelong(age)) + .filter(ageRange -> ageRange.ageGroup == ageGroup) .findAny() .orElseThrow(() -> new BadRequestException(MemberExceptionType.INVALID_AGE)); } - private boolean isBelong(final int age) { - return this.startAge <= age && this.endAge >= age; - } - } diff --git a/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java b/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java index e4f397264..e77a60324 100644 --- a/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java +++ b/backend/src/main/java/com/votogether/domain/member/entity/vo/Nickname.java @@ -9,27 +9,28 @@ import lombok.Getter; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Nickname { private static final int MINIMUM_NICKNAME_LENGTH = 2; private static final int MAXIMUM_NICKNAME_LENGTH = 15; + private static final Pattern PATTERN = Pattern.compile("^[가-힣a-zA-Z0-9]+$"); @Column(name = "nickname", length = 20, unique = true, nullable = false) private String value; - public Nickname(final String nickname) { - validateNickname(nickname); - this.value = nickname; + public Nickname(final String value) { + validateNickname(value); + this.value = value; } - private void validateNickname(final String nickname) { - if (nickname.length() < MINIMUM_NICKNAME_LENGTH || nickname.length() > MAXIMUM_NICKNAME_LENGTH) { + private void validateNickname(final String value) { + if (value.length() < MINIMUM_NICKNAME_LENGTH || value.length() > MAXIMUM_NICKNAME_LENGTH) { throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_LENGTH); } - if (!Pattern.matches("^[가-힣a-zA-Z0-9]+$", nickname)) { + if (!PATTERN.matcher(value).matches()) { throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_LETTER); } } diff --git a/backend/src/main/java/com/votogether/domain/member/service/MemberService.java b/backend/src/main/java/com/votogether/domain/member/service/MemberService.java index 55705f9db..adc6bdb7f 100644 --- a/backend/src/main/java/com/votogether/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/votogether/domain/member/service/MemberService.java @@ -116,7 +116,7 @@ private List deletePosts(final Member member) { } private List deleteComments(final Member member) { - final List comments = commentRepository.findAllByMember(member); + final List comments = commentRepository.findAllByWriter(member); final List commentIds = comments.stream() .map(Comment::getId) .toList(); diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommandController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommandController.java new file mode 100644 index 000000000..b34687516 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommandController.java @@ -0,0 +1,69 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.request.post.PostCreateRequest; +import com.votogether.domain.post.dto.request.post.PostUpdateRequest; +import com.votogether.domain.post.service.PostCommandService; +import com.votogether.global.jwt.Auth; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RequiredArgsConstructor +@RequestMapping("/posts") +@RestController +public class PostCommandController { + + private final PostCommandService postCommandService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + public ResponseEntity createPost( + @ModelAttribute @Valid final PostCreateRequest postCreateRequest, + @Auth final Member loginMember + ) { + final Long postId = postCommandService.createPost(postCreateRequest, loginMember); + return ResponseEntity.created(URI.create("/posts/" + postId)).build(); + } + + @PutMapping(value = "/{postId}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + public ResponseEntity updatePost( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @ModelAttribute @Valid final PostUpdateRequest postUpdateRequest, + @Auth final Member loginMember + ) { + postCommandService.updatePost(postId, postUpdateRequest, loginMember); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/{postId}/close") + public ResponseEntity closePostEarly( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Auth final Member loginMember + ) { + postCommandService.closePostEarly(postId, loginMember); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{postId}") + public ResponseEntity deletePost( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Auth final Member loginMember + ) { + postCommandService.deletePost(postId, loginMember); + return ResponseEntity.noContent().build(); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommandControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommandControllerDocs.java new file mode 100644 index 000000000..a9800bf15 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommandControllerDocs.java @@ -0,0 +1,130 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.request.post.PostCreateRequest; +import com.votogether.domain.post.dto.request.post.PostUpdateRequest; +import com.votogether.global.exception.ExceptionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; + +@Tag(name = "게시글 커맨드", description = "게시글 커맨드 API") +public interface PostCommandControllerDocs { + + @Operation(summary = "게시글 작성", description = "게시글을 작성한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "게시글 작성 성공" + ), + @ApiResponse( + responseCode = "400", + description = "정상적이지 않은 요청", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity createPost( + @Valid final PostCreateRequest postCreateRequest, + final Member loginMember + ); + + @Operation(summary = "게시글 수정", description = "게시글을 수정한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 수정 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.게시글 ID가 양의 정수가 아닌 경우 + + 2.정상적이지 않은 요청 + + 3.게시글이 블라인드 처리된 경우 + + 4.게시글 작성자가 아닌 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity updatePost( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Valid final PostUpdateRequest postUpdateRequest, + final Member loginMember + ); + + @Operation(summary = "게시글 조기 마감", description = "게시글을 조기 마감한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 조기 마감 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.게시글 ID가 양의 정수가 아닌 경우 + + 2.게시글이 블라인드 처리된 경우 + + 3.게시글 작성자가 아닌 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity closePostEarly( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + final Member loginMember + ); + + @Operation(summary = "게시글 삭제", description = "게시글을 삭제한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "게시글 삭제 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.게시글 ID가 양의 정수가 아닌 경우 + + 2.게시글이 블라인드 처리된 경우 + + 3.게시글 작성자가 아닌 경우 + + 4.삭제할 수 없는 투표 수가 존재하는 게시글인 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity deletePost( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + final Member loginMember + ); + +} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java index 0d2ef6e6b..3cc78842e 100644 --- a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentController.java @@ -1,16 +1,18 @@ package com.votogether.domain.post.controller; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; +import com.votogether.domain.post.dto.request.comment.CommentCreateRequest; import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest; import com.votogether.domain.post.dto.response.comment.CommentResponse; import com.votogether.domain.post.service.PostCommentService; import com.votogether.global.jwt.Auth; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Validated @RequiredArgsConstructor @RequestMapping("/posts") @RestController @@ -28,39 +31,41 @@ public class PostCommentController implements PostCommentControllerDocs { private final PostCommentService postCommentService; @GetMapping("/{postId}/comments") - public ResponseEntity> getComments(@PathVariable final Long postId) { + public ResponseEntity> getComments( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId + ) { final List response = postCommentService.getComments(postId); return ResponseEntity.ok(response); } @PostMapping("/{postId}/comments") public ResponseEntity createComment( - @PathVariable final Long postId, - @Valid @RequestBody CommentRegisterRequest commentRegisterRequest, - @Auth final Member member + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @RequestBody @Valid CommentCreateRequest commentCreateRequest, + @Auth final Member loginMember ) { - postCommentService.createComment(member, postId, commentRegisterRequest); + postCommentService.createComment(postId, commentCreateRequest, loginMember); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PutMapping("/{postId}/comments/{commentId}") public ResponseEntity updateComment( - @PathVariable final Long postId, - @PathVariable final Long commentId, + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @PathVariable @Positive(message = "댓글 ID는 양의 정수만 가능합니다.") final Long commentId, @RequestBody @Valid final CommentUpdateRequest commentUpdateRequest, - @Auth final Member member + @Auth final Member loginMember ) { - postCommentService.updateComment(postId, commentId, commentUpdateRequest, member); + postCommentService.updateComment(postId, commentId, commentUpdateRequest, loginMember); return ResponseEntity.ok().build(); } @DeleteMapping("/{postId}/comments/{commentId}") public ResponseEntity deleteComment( - @PathVariable final Long postId, - @PathVariable final Long commentId, - @Auth final Member member + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @PathVariable @Positive(message = "댓글 ID는 양의 정수만 가능합니다.") final Long commentId, + @Auth final Member loginMember ) { - postCommentService.deleteComment(postId, commentId, member); + postCommentService.deleteComment(postId, commentId, loginMember); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java index fecb9b3d8..c5572b0db 100644 --- a/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostCommentControllerDocs.java @@ -1,7 +1,7 @@ package com.votogether.domain.post.controller; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; +import com.votogether.domain.post.dto.request.comment.CommentCreateRequest; import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest; import com.votogether.domain.post.dto.response.comment.CommentResponse; import com.votogether.global.exception.ExceptionResponse; @@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import java.util.List; import org.springframework.http.ResponseEntity; @@ -20,7 +22,19 @@ public interface PostCommentControllerDocs { @Operation(summary = "게시글 댓글 목록 조회", description = "게시글 댓글 목록을 조회한다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 댓글 목록 조회 성공"), + @ApiResponse( + responseCode = "200", + description = "게시글 댓글 목록 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.게시글이 블라인드 처리된 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), @ApiResponse( responseCode = "404", description = "존재하지 않는 게시글", @@ -28,12 +42,27 @@ public interface PostCommentControllerDocs { ) }) ResponseEntity> getComments( - @Parameter(description = "댓글 작성 게시글 ID", example = "1") final Long postId + @Parameter(description = "댓글 작성 게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId ); @Operation(summary = "게시글 댓글 작성", description = "게시글 댓글을 작성한다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 댓글 작성 성공"), + @ApiResponse( + responseCode = "201", + description = "게시글 댓글 작성 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.존재하지 않는 댓글 내용 + + 3.게시글이 블라인드 처리된 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), @ApiResponse( responseCode = "404", description = "존재하지 않는 게시글", @@ -41,50 +70,95 @@ ResponseEntity> getComments( ) }) ResponseEntity createComment( - @Parameter(description = "댓글 작성 게시글 ID", example = "1") final Long postId, - final CommentRegisterRequest commentRegisterRequest, - final Member member + @Parameter(description = "댓글 작성 게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Valid final CommentCreateRequest commentCreateRequest, + final Member loginMember ); @Operation(summary = "게시글 댓글 수정", description = "게시글 댓글을 수정한다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 댓글 수정 성공"), + @ApiResponse( + responseCode = "200", + description = "게시글 댓글 수정 성공" + ), @ApiResponse( responseCode = "400", - description = "1.게시글에 속하지 않은 댓글\t\n2.올바르지 않은 댓글 작성자", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.양의 정수가 아닌 댓글 ID + + 3.존재하지 않는 댓글 내용 + + 4.게시글이 블라인드 처리된 경우 + + 5.댓글이 블라인드 처리된 경우 + + 6.게시글의 댓글이 아닌 경우 + + 7.댓글 작성자가 아닌 경우 + """, content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) ), @ApiResponse( responseCode = "404", - description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 댓글", + description = """ + 1.존재하지 않는 게시글 + + 2.존재하지 않는 댓글 + """, content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) ) }) ResponseEntity updateComment( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - @Parameter(description = "댓글 ID", example = "1") final Long commentId, - final CommentUpdateRequest commentUpdateRequest, - final Member member + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Parameter(description = "댓글 ID", example = "1") + @Positive(message = "댓글 ID는 양의 정수만 가능합니다.") final Long commentId, + @Valid final CommentUpdateRequest commentUpdateRequest, + final Member loginMember ); @Operation(summary = "게시글 댓글 삭제", description = "게시글 댓글을 삭제한다.") @ApiResponses({ - @ApiResponse(responseCode = "204", description = "게시글 댓글 삭제 성공"), + @ApiResponse( + responseCode = "204", + description = "게시글 댓글 삭제 성공" + ), @ApiResponse( responseCode = "400", - description = "1.게시글에 속하지 않은 댓글\t\n2.올바르지 않은 댓글 작성자", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.양의 정수가 아닌 댓글 ID + + 3.게시글이 블라인드 처리된 경우 + + 4.댓글이 블라인드 처리된 경우 + + 5.게시글의 댓글이 아닌 경우 + + 6.댓글 작성자가 아닌 경우 + """, content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) ), @ApiResponse( responseCode = "404", - description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 댓글", + description = """ + 1.존재하지 않는 게시글 + + 2.존재하지 않는 댓글 + """, content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) ) }) ResponseEntity deleteComment( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - @Parameter(description = "댓글 ID", example = "1") final Long commentId, - final Member member + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Parameter(description = "댓글 ID", example = "1") + @Positive(message = "댓글 ID는 양의 정수만 가능합니다.") final Long commentId, + final Member loginMember ); } diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostController.java deleted file mode 100644 index 076fc86c0..000000000 --- a/backend/src/main/java/com/votogether/domain/post/controller/PostController.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.votogether.domain.post.controller; - -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.request.post.PostCreateRequest; -import com.votogether.domain.post.dto.request.post.PostUpdateRequest; -import com.votogether.domain.post.dto.response.post.PostDetailResponse; -import com.votogether.domain.post.dto.response.post.PostRankingResponse; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.post.service.PostService; -import com.votogether.global.jwt.Auth; -import jakarta.validation.Valid; -import java.net.URI; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RequiredArgsConstructor -@RequestMapping("/posts") -@RestController -public class PostController implements PostControllerDocs { - - private final PostService postService; - - @GetMapping("/search") - public ResponseEntity> searchPostsWithKeyword( - @RequestParam final String keyword, - @RequestParam final int page, - @RequestParam final PostClosingType postClosingType, - @RequestParam final PostSortType postSortType, - @RequestParam(required = false, name = "category") final Long categoryId, - @Auth final Member member - ) { - final List responses = - postService.searchPostsWithKeyword(keyword, page, postClosingType, postSortType, categoryId, member); - return ResponseEntity.ok(responses); - } - - @GetMapping("/search/guest") - public ResponseEntity> searchPostsWithKeywordForGuest( - @RequestParam final String keyword, - @RequestParam final int page, - @RequestParam final PostClosingType postClosingType, - @RequestParam final PostSortType postSortType, - @RequestParam(required = false, name = "category") final Long categoryId - ) { - final List responses = - postService.searchPostsWithKeywordForGuest(keyword, page, postClosingType, postSortType, categoryId); - return ResponseEntity.ok(responses); - } - - @GetMapping("/me") - public ResponseEntity> getPostsByMe( - @RequestParam final int page, - @RequestParam final PostClosingType postClosingType, - @RequestParam final PostSortType postSortType, - @RequestParam(required = false, name = "category") final Long categoryId, - @Auth final Member member - ) { - final List responses = - postService.getPostsByWriter(page, postClosingType, postSortType, categoryId, member); - return ResponseEntity.ok(responses); - } - - @GetMapping - public ResponseEntity> getAllPost( - @RequestParam final int page, - @RequestParam final PostClosingType postClosingType, - @RequestParam final PostSortType postSortType, - @RequestParam(name = "category", required = false) final Long categoryId, - @Auth final Member member - ) { - final List responses = postService.getAllPostBySortTypeAndClosingTypeAndCategoryId( - page, - postClosingType, - postSortType, - categoryId, - member - ); - return ResponseEntity.ok(responses); - } - - @GetMapping("/guest") - public ResponseEntity> getPostsGuest( - @RequestParam final int page, - @RequestParam final PostClosingType postClosingType, - @RequestParam final PostSortType postSortType, - @RequestParam(required = false, name = "category") final Long categoryId - ) { - final List response = postService.getPostsGuest(page, postClosingType, postSortType, categoryId); - return ResponseEntity.ok(response); - } - - @GetMapping("{postId}") - public ResponseEntity getPost( - @PathVariable final Long postId, - @Auth final Member member - ) { - final PostDetailResponse response = postService.getPostById(postId, member); - return ResponseEntity.ok(response); - } - - @GetMapping("{postId}/guest") - public ResponseEntity getPostByGuest( - @PathVariable final Long postId - ) { - final PostDetailResponse response = postService.getPostById(postId, null); - return ResponseEntity.ok(response); - } - - @GetMapping("/{postId}/options") - public ResponseEntity getVoteStatistics( - @PathVariable final Long postId, - @Auth final Member member - ) { - final VoteOptionStatisticsResponse response = postService.getVoteStatistics(postId, member); - return ResponseEntity.ok(response); - } - - @GetMapping("/{postId}/options/{optionId}") - public ResponseEntity getVoteOptionStatistics( - @PathVariable final Long postId, - @PathVariable final Long optionId, - @Auth final Member member - ) { - final VoteOptionStatisticsResponse response = postService.getVoteOptionStatistics(postId, optionId, member); - return ResponseEntity.ok(response); - } - - @GetMapping("/votes/me") - public ResponseEntity> getPostsVotedByMe( - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - @Auth final Member member - ) { - final List posts = postService.getPostsVotedByMember(page, postClosingType, postSortType, member); - return ResponseEntity.status(HttpStatus.OK).body(posts); - } - - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity save( - @RequestPart @Valid final PostCreateRequest request, - @RequestPart final List contentImages, - @RequestPart final List optionImages, - @Auth final Member member - ) { - System.out.println("PostController.save"); - - System.out.println("contentImages = " + contentImages); - if (contentImages != null && !contentImages.isEmpty()) { - System.out.println("contentImages = " + contentImages.get(0).getOriginalFilename()); - } - - System.out.println("optionImages = " + optionImages); - if (optionImages != null && !optionImages.isEmpty()) { - System.out.println("optionImages1 = " + optionImages.get(0).getOriginalFilename()); - System.out.println("optionImages2 = " + optionImages.get(1).getOriginalFilename()); - } - final Long postId = postService.save(request, member, contentImages, optionImages); - return ResponseEntity.created(URI.create("/posts/" + postId)).build(); - } - - @GetMapping("ranking/popular/guest") - public ResponseEntity> getRanking() { - final List responses = postService.getRanking(); - return ResponseEntity.ok(responses); - } - - @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity update( - @PathVariable final Long postId, - @RequestPart @Valid final PostUpdateRequest request, - @RequestPart final List contentImages, - @RequestPart final List optionImages, - @Auth final Member member - ) { - System.out.println("PostController.update"); - - System.out.println("contentImages = " + contentImages); - if (contentImages != null && !contentImages.isEmpty()) { - System.out.println("contentImages = " + contentImages.get(0).getOriginalFilename()); - } - - System.out.println("optionImages = " + optionImages); - if (optionImages != null && !optionImages.isEmpty()) { - System.out.println("optionImages1 = " + optionImages.get(0).getOriginalFilename()); - System.out.println("optionImages2 = " + optionImages.get(1).getOriginalFilename()); - } - - postService.update(postId, request, member, contentImages, optionImages); - return ResponseEntity.ok().build(); - } - - @PatchMapping("/{postId}/close") - public ResponseEntity closePostEarly( - @PathVariable final Long postId, - @Auth final Member loginMember - ) { - postService.closePostEarlyById(postId, loginMember); - return ResponseEntity.ok().build(); - } - - @DeleteMapping("/{postId}") - public ResponseEntity delete(@PathVariable final Long postId) { - postService.delete(postId); - return ResponseEntity.noContent().build(); - } - -} - - diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java deleted file mode 100644 index e87b40d0f..000000000 --- a/backend/src/main/java/com/votogether/domain/post/controller/PostControllerDocs.java +++ /dev/null @@ -1,254 +0,0 @@ -package com.votogether.domain.post.controller; - -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.request.post.PostCreateRequest; -import com.votogether.domain.post.dto.request.post.PostUpdateRequest; -import com.votogether.domain.post.dto.response.post.PostDetailResponse; -import com.votogether.domain.post.dto.response.post.PostRankingResponse; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.global.exception.ExceptionResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import org.springframework.http.ResponseEntity; -import org.springframework.web.multipart.MultipartFile; - -@Tag(name = "게시글", description = "게시글 API") -public interface PostControllerDocs { - - @Operation(summary = "[회원] 전체 게시글 목록 조회", description = "[회원] 전체 게시글 목록을 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "전체 게시글 목록 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> getAllPost( - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - @Parameter(description = "카테고리 ID", example = "1") final Long categoryId, - final Member member - ); - - @Operation(summary = "[비회원] 전체 게시글 목록 조회", description = "[비회원] 전체 게시글 목록을 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "전체 게시글 목록 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> getPostsGuest( - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - @Parameter(description = "카테고리 ID", example = "1") final Long categoryId - ); - - @Operation(summary = "[회원] 게시글 상세 조회", description = "[회원] 게시글을 상세 조회 한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 상세 조회 성공"), - @ApiResponse( - responseCode = "404", - description = "존재하지 않는 게시글", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity getPost( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - final Member member - ); - - @Operation(summary = "[비회원] 게시글 상세 조회", description = "[비회원] 게시글을 상세 조회 한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 상세 조회 성공"), - @ApiResponse( - responseCode = "404", - description = "존재하지 않는 게시글", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity getPostByGuest( - @Parameter(description = "게시글 ID", example = "1") final Long postId - ); - - @Operation(summary = "게시글 투표 통계 조회", description = "게시글 투표에 대한 전체 통계를 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 투표 통계 조회 성공"), - @ApiResponse( - responseCode = "404", - description = "존재하지 않는 게시글", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity getVoteStatistics( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - final Member member - ); - - @Operation(summary = "게시글 투표 선택지 통계 조회", description = "게시글 특정 투표 선택지에 대한 통계를 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 투표 선택지 통계 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "게시글에 속하지 않는 투표 옵션", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ), - @ApiResponse( - responseCode = "404", - description = "1.존재하지 않는 게시글\t\n2.존재하지 않는 게시글 투표 옵션", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity getVoteOptionStatistics( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - @Parameter(description = "게시글 선택지 ID", example = "2") final Long optionId, - final Member member - ); - - @Operation(summary = "투표한 게시글 목록 조회", description = "투표한 게시글 목록을 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "투표한 게시글 목록 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> getPostsVotedByMe( - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - final Member member - ); - - - @Operation(summary = "[회원] 게시글 검색", description = "[회원] 키워드를 통해 게시글을 검색한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 검색 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> searchPostsWithKeyword( - @Parameter(description = "검색 키워드", example = "취업") final String keyword, - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - @Parameter(description = "카테고리 ID", example = "1") final Long categoryId, - final Member member - ); - - @Operation(summary = "[비회원] 게시글 검색", description = "[비회원] 키워드를 통해 게시글을 검색한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 검색 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> searchPostsWithKeywordForGuest( - @Parameter(description = "검색 키워드", example = "취업") final String keyword, - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - @Parameter(description = "카테고리 ID", example = "1") final Long categoryId - ); - - @Operation(summary = "작성한 게시글 목록 조회", description = "작성한 게시글 목록을 조회한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "작성한 게시글 목록 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity> getPostsByMe( - @Parameter(description = "현재 페이지 위치", example = "0") final int page, - @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, - @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, - @Parameter(description = "카테고리 ID", example = "1") final Long categoryId, - final Member member - ); - - @Operation(summary = "인기 게시글 랭킹 조회", description = "인기 게시글 랭킹을 조회한다.") - @ApiResponse(responseCode = "200", description = "인기 게시글 랭킹 조회 성공") - ResponseEntity> getRanking(); - - @Operation(summary = "게시글 작성", description = "게시글을 작성한다.") - @ApiResponses({ - @ApiResponse(responseCode = "201", description = "게시글 작성 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력입니다.", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity save( - final PostCreateRequest request, - final List contentImages, - final List optionImages, - final Member member - ); - - @Operation(summary = "게시글 수정", description = "게시글을 수정한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시글 수정 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity update( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - final PostUpdateRequest request, - final List contentImages, - final List optionImages, - final Member member - ); - - @Operation(summary = "게시글 조기 마감", description = "게시글을 조기 마감한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시물 조기 마감 성공."), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity closePostEarly( - @Parameter(description = "게시글 ID", example = "1") final Long postId, - final Member loginMember - ); - - @Operation(summary = "게시글 삭제", description = "게시글을 삭제한다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "게시물 삭제 성공"), - @ApiResponse( - responseCode = "400", - description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - ResponseEntity delete( - @Parameter(description = "게시글 ID", example = "1") final Long postId - ); - -} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostGuestController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostGuestController.java new file mode 100644 index 000000000..8bc0e8caa --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostGuestController.java @@ -0,0 +1,64 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.post.dto.response.post.PostRankingResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostGuestService; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RequiredArgsConstructor +@RequestMapping("/posts") +@RestController +public class PostGuestController implements PostGuestControllerDocs { + + private final PostGuestService postGuestService; + + @GetMapping("/guest") + public ResponseEntity> getPosts( + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType, + @RequestParam(name = "category", required = false) final Long categoryId + ) { + final List response = postGuestService.getPosts(page, postClosingType, postSortType, categoryId); + return ResponseEntity.ok(response); + } + + @GetMapping("{postId}/guest") + public ResponseEntity getPost( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId + ) { + final PostResponse response = postGuestService.getPost(postId); + return ResponseEntity.ok(response); + } + + @GetMapping("/search/guest") + public ResponseEntity> searchPosts( + @RequestParam final String keyword, + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType + ) { + final List responses = postGuestService.searchPosts(keyword, page, postClosingType, postSortType); + return ResponseEntity.ok(responses); + } + + @GetMapping("ranking/popular/guest") + public ResponseEntity> getRanking() { + final List responses = postGuestService.getRanking(); + return ResponseEntity.ok(responses); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostGuestControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostGuestControllerDocs.java new file mode 100644 index 000000000..145b0b5a4 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostGuestControllerDocs.java @@ -0,0 +1,93 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.post.dto.response.post.PostRankingResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.global.exception.ExceptionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import org.springframework.http.ResponseEntity; + +@Tag(name = "비회원 게시글", description = "비회원 게시글 API") +public interface PostGuestControllerDocs { + + @Operation(summary = "비회원 게시글 목록 조회", description = "비회원이 게시글 목록을 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "비회원 게시글 목록 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0 이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> getPosts( + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, + @Parameter(description = "카테고리 ID", example = "1") final Long categoryId + ); + + @Operation(summary = "비회원 게시글 상세 조회", description = "비회원이 게시글을 상세 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "비회원 게시글 상세 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.게시글이 블라인드 처리된 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity getPost( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId + ); + + @Operation(summary = "비회원 게시글 검색", description = "비회원이 게시글을 검색한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "비회원 게시글 검색 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0 이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> searchPosts( + @Parameter(description = "검색 키워드", example = "votogether") final String keyword, + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType + ); + + @Operation(summary = "인기 게시글 랭킹 조회", description = "인기 게시글 랭킹을 조회한다.") + @ApiResponse(responseCode = "200", description = "인기 게시글 랭킹 조회 성공") + ResponseEntity> getRanking(); + +} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostQueryController.java b/backend/src/main/java/com/votogether/domain/post/controller/PostQueryController.java new file mode 100644 index 000000000..6f6bfe1a2 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostQueryController.java @@ -0,0 +1,109 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostQueryService; +import com.votogether.global.jwt.Auth; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RequiredArgsConstructor +@RequestMapping("/posts") +@RestController +public class PostQueryController implements PostQueryControllerDocs { + + private final PostQueryService postQueryService; + + @GetMapping + public ResponseEntity> getPosts( + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType, + @RequestParam(name = "category", required = false) final Long categoryId, + @Auth final Member loginMember + ) { + final List response = + postQueryService.getPosts(page, postClosingType, postSortType, categoryId, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("{postId}") + public ResponseEntity getPost( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Auth final Member loginMember + ) { + final PostResponse response = postQueryService.getPost(postId, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("/search") + public ResponseEntity> searchPosts( + @RequestParam final String keyword, + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType, + @Auth final Member loginMember + ) { + final List response = + postQueryService.searchPosts(keyword, page, postClosingType, postSortType, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("/me") + public ResponseEntity> getPostsWrittenByMe( + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType, + @Auth final Member loginMember + ) { + final List response = + postQueryService.getPostsWrittenByMe(page, postClosingType, postSortType, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("/votes/me") + public ResponseEntity> getPostsVotedByMe( + @RequestParam @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @RequestParam final PostClosingType postClosingType, + @RequestParam final PostSortType postSortType, + @Auth final Member loginMember + ) { + final List response = + postQueryService.getPostsVotedByMe(page, postClosingType, postSortType, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("/{postId}/options") + public ResponseEntity getVoteStatistics( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Auth final Member loginMember + ) { + final VoteOptionStatisticsResponse response = postQueryService.getVoteStatistics(postId, loginMember); + return ResponseEntity.ok(response); + } + + @GetMapping("/{postId}/options/{optionId}") + public ResponseEntity getVoteOptionStatistics( + @PathVariable @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @PathVariable @Positive(message = "게시글 옵션 ID는 양의 정수만 가능합니다.") final Long optionId, + @Auth final Member loginMember + ) { + final VoteOptionStatisticsResponse response = + postQueryService.getVoteOptionStatistics(postId, optionId, loginMember); + return ResponseEntity.ok(response); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/controller/PostQueryControllerDocs.java b/backend/src/main/java/com/votogether/domain/post/controller/PostQueryControllerDocs.java new file mode 100644 index 000000000..c584d4a8b --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/controller/PostQueryControllerDocs.java @@ -0,0 +1,201 @@ +package com.votogether.domain.post.controller; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.global.exception.ExceptionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.List; +import org.springframework.http.ResponseEntity; + +@Tag(name = "게시글 조회", description = "게시글 조회 API") +public interface PostQueryControllerDocs { + + @Operation(summary = "게시글 목록 조회", description = "게시글 목록을 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> getPosts( + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, + @Parameter(description = "카테고리 ID", example = "1") final Long categoryId, + final Member loginMember + ); + + @Operation(summary = "게시글 상세 조회", description = "게시글을 상세 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 상세 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.게시글이 블라인드 처리된 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity getPost( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + final Member loginMember + ); + + @Operation(summary = "게시글 검색", description = "게시글을 검색한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 검색 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> searchPosts( + @Parameter(description = "검색 키워드", example = "hello") final String keyword, + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, + final Member loginMember + ); + + @Operation(summary = "내가 작성한 게시글 조회", description = "내가 작성한 게시글을 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내가 작성한 게시글 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> getPostsWrittenByMe( + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, + final Member loginMember + ); + + @Operation(summary = "내가 투표한 게시글 조회", description = "내가 투표한 게시글을 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내가 투표한 게시글 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = "0이상의 정수가 아닌 페이지", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity> getPostsVotedByMe( + @Parameter(description = "현재 페이지 위치", example = "0") + @PositiveOrZero(message = "페이지는 0이상 정수만 가능합니다.") final int page, + @Parameter(description = "게시글 마감 여부", example = "ALL") final PostClosingType postClosingType, + @Parameter(description = "게시글 정렬 기준", example = "HOT") final PostSortType postSortType, + final Member loginMember + ); + + @Operation(summary = "게시글 투표 통계 조회", description = "게시글 투표 통계를 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 투표 통계 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.게시글이 블라인드 처리된 경우 + + 3.게시글 작성자가 아닌 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity getVoteStatistics( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + final Member loginMember + ); + + @Operation(summary = "게시글 투표 옵션 통계 조회", description = "게시글 투표 옵션 통계를 조회한다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 투표 옵션 통계 조회 성공" + ), + @ApiResponse( + responseCode = "400", + description = """ + 1.양의 정수가 아닌 게시글 ID + + 2.양의 정수가 아닌 게시글 투표 옵션 ID + + 3.게시글이 블라인드 처리된 경우 + + 4.게시글 작성자가 아닌 경우 + + 5.옵션이 해당 게시글에 속하지 않는 경우 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = """ + 1.존재하지 않는 게시글 + + 2.존재하지 않는 게시글 투표 옵션 + """, + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + ResponseEntity getVoteOptionStatistics( + @Parameter(description = "게시글 ID", example = "1") + @Positive(message = "게시글 ID는 양의 정수만 가능합니다.") final Long postId, + @Parameter(description = "게시글 옵션 ID", example = "1") + @Positive(message = "게시글 옵션 ID는 양의 정수만 가능합니다.") final Long optionId, + final Member loginMember + ); + +} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentCreateRequest.java similarity index 90% rename from backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java rename to backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentCreateRequest.java index ef6f641f4..8a2a2275c 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentRegisterRequest.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/request/comment/CommentCreateRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; @Schema(description = "댓글 작성 요청") -public record CommentRegisterRequest( +public record CommentCreateRequest( @Schema(description = "댓글 내용", example = "hello") @NotBlank(message = "댓글 내용은 존재해야 합니다.") String content diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java index 4bfc21708..8c7e91305 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostCreateRequest.java @@ -1,6 +1,5 @@ package com.votogether.domain.post.dto.request.post; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -8,37 +7,44 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; -import lombok.Builder; -import org.hibernate.validator.constraints.Length; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.multipart.MultipartFile; +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor @Schema(description = "게시글 작성 요청") -@Builder -public record PostCreateRequest( - @Schema(description = "카테고리의 여러 아이디", example = "[1, 3]") - @Size(min = 1, message = "게시글에 해당하는 카테고리는 최소 1개 이상이어야 합니다.") - List categoryIds, - - @Schema(description = "게시글 제목", example = "title") - @NotBlank(message = "제목을 입력해주세요.") - @Length(max = 100, message = "제목은 최대 100자까지 입력 가능합니다.") - String title, - - @Schema(description = "게시글 내용", example = "content") - @NotBlank(message = "내용을 입력해주세요.") - @Length(max = 1000, message = "내용은 최대 1000자까지 입력 가능합니다.") - String content, - - @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com") - String imageUrl, - - @Schema(description = "게시글의 여러 선택지") - @Valid - @NotNull(message = "선택지는 최소 2개 이상 등록해야 합니다.") - @Size(min = 2, max = 5, message = "선택지는 최소 2개, 최대 5개까지 등록 가능합니다.") - List postOptions, - - @Schema(description = "마감 기한", example = "2023-08-01 15:30") - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm") - LocalDateTime deadline -) { +public class PostCreateRequest { + + @Schema(description = "카테고리 ID 목록", example = "[1, 2]") + @NotNull(message = "게시글 카테고리 ID가 등록되지 않았습니다.") + @Size(min = 1, message = "게시글 카테고리는 최소 1개 이상 등록이 필요합니다.") + private List categoryIds; + + @Schema(description = "게시글 제목", example = "보투게더에 대하여") + @NotBlank(message = "게시글 제목이 존재하지 않거나 공백만 존재합니다.") + private String title; + + @Schema(description = "게시글 내용", example = "보투게더 정말 재밌어요!") + @NotBlank(message = "게시글 내용이 존재하지 않거나 공백만 존재합니다.") + private String content; + + @Schema(description = "게시글 이미지 파일", example = "votogether.png") + private MultipartFile imageFile; + + @Schema(description = "게시글 옵션 작성 목록") + @Valid + private List postOptions; + + @Schema(description = "게시글 마감시간", example = "2023-08-01 12:25") + @NotNull(message = "게시글 마감시간을 등록하지 않았습니다.") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime deadline; + } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java index f5c703126..6af69ecb0 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionCreateRequest.java @@ -2,18 +2,25 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import lombok.Builder; -import org.hibernate.validator.constraints.Length; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; -@Schema(description = "게시글 선택지 요청") -@Builder -public record PostOptionCreateRequest( - @Schema(description = "선택지 내용", example = "content") - @NotBlank(message = "해당 선택지의 내용을 입력해주세요.") - @Length(max = 50, message = "선택지의 내용은 최대 50자까지 입력 가능합니다.") - String content, +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Schema(description = "게시글 옵션 작성 요청") +public class PostOptionCreateRequest { + + @Schema(description = "게시글 옵션 내용", example = "1번 옵션") + @NotBlank(message = "게시글 옵션 내용이 존재하지 않거나 공백만 존재합니다.") + private String content; + + @Schema(description = "게시글 옵션 이미지 파일", example = "votogether.png") + private MultipartFile imageFile; - @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com") - String imageUrl -) { } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java index a321d2993..3b9a7cb14 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostOptionUpdateRequest.java @@ -2,18 +2,31 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import lombok.Builder; -import org.hibernate.validator.constraints.Length; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; -@Schema(description = "게시글 선택지 수정 요청") -@Builder -public record PostOptionUpdateRequest( - @Schema(description = "선택지 내용", example = "content") - @NotBlank(message = "해당 선택지의 내용을 입력해주세요.") - @Length(max = 50, message = "선택지의 내용은 최대 50자까지 입력 가능합니다.") - String content, +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Schema(description = "게시글 옵션 작성 요청") +public class PostOptionUpdateRequest { + + @Schema(description = "게시글 옵션 ID", example = "1") + private Long id; + + @Schema(description = "게시글 옵션 내용", example = "1번 옵션") + @NotBlank(message = "게시글 옵션 내용이 존재하지 않거나 공백만 존재합니다.") + private String content; + + @Schema(description = "게시글 옵션 이미지 URL", example = "https://test.com/image.png") + private String imageUrl; + + @Schema(description = "게시글 옵션 이미지 파일", example = "votogether.png") + private MultipartFile imageFile; - @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com") - String imageUrl -) { } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java index 1dfed74c8..d9c738577 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/request/post/PostUpdateRequest.java @@ -1,6 +1,5 @@ package com.votogether.domain.post.dto.request.post; -import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -8,37 +7,47 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; -import lombok.Builder; -import org.hibernate.validator.constraints.Length; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.multipart.MultipartFile; +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor @Schema(description = "게시글 수정 요청") -@Builder -public record PostUpdateRequest( - @Schema(description = "카테고리의 여러 아이디", example = "[0, 2]") - @Size(min = 1, message = "게시글에 해당하는 카테고리는 최소 1개 이상이어야 합니다.") - List categoryIds, - - @Schema(description = "게시글 제목", example = "title") - @NotBlank(message = "제목을 입력해주세요.") - @Length(max = 100, message = "제목은 최대 100자까지 입력 가능합니다.") - String title, - - @Schema(description = "게시글 내용", example = "content") - @NotBlank(message = "내용을 입력해주세요.") - @Length(max = 1000, message = "내용은 최대 1000자까지 입력 가능합니다.") - String content, - - @Schema(description = "이미지 URL", example = "http://asdasdsadsad.com") - String imageUrl, - - @Schema(description = "게시글의 여러 선택지") - @Valid - @NotNull(message = "선택지는 최소 2개 이상 등록해야 합니다.") - @Size(min = 2, max = 5, message = "선택지는 최소 2개, 최대 5개까지 등록 가능합니다.") - List postOptions, - - @Schema(description = "마감 기한", example = "2023-08-01 15:30") - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm") - LocalDateTime deadline -) { +public class PostUpdateRequest { + + @Schema(description = "카테고리 ID 목록", example = "[1, 2]") + @NotNull(message = "게시글 카테고리 ID가 등록되지 않았습니다.") + @Size(min = 1, message = "게시글 카테고리는 최소 1개 이상 등록이 필요합니다.") + private List categoryIds; + + @Schema(description = "게시글 제목", example = "보투게더에 대하여") + @NotBlank(message = "게시글 제목이 존재하지 않거나 공백만 존재합니다.") + private String title; + + @Schema(description = "게시글 내용", example = "보투게더 정말 재밌어요!") + @NotBlank(message = "게시글 내용이 존재하지 않거나 공백만 존재합니다.") + private String content; + + @Schema(description = "게시글 이미지 URL", example = "https://test.com/image.png") + private String imageUrl; + + @Schema(description = "게시글 이미지 파일", example = "votogether.png") + private MultipartFile imageFile; + + @Schema(description = "게시글 옵션 작성 목록") + @Valid + private List postOptions; + + @Schema(description = "게시글 마감시간", example = "2023-08-01 12:25") + @NotNull(message = "게시글 마감시간을 등록하지 않았습니다.") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm") + private LocalDateTime deadline; + } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java index a1e14588f..917371c84 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentResponse.java @@ -1,7 +1,6 @@ package com.votogether.domain.post.dto.response.comment; import com.fasterxml.jackson.annotation.JsonFormat; -import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.comment.Comment; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -11,17 +10,17 @@ public record CommentResponse( @Schema(description = "댓글 ID", example = "1") Long id, - @Schema(description = "댓글 작성자 회원") - CommentMember member, + @Schema(description = "댓글 작성자") + CommentWriterResponse member, @Schema(description = "댓글 내용", example = "재밌어요!") String content, - @Schema(description = "댓글 작성시각", example = "2023-08-01 10:56") + @Schema(description = "댓글 작성시간", example = "2023-08-01 10:56") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime createdAt, - @Schema(description = "댓글 수정시각", example = "2023-08-01 13:56") + @Schema(description = "댓글 수정시간", example = "2023-08-01 13:56") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime updatedAt ) { @@ -29,26 +28,10 @@ public record CommentResponse( public static CommentResponse from(final Comment comment) { return new CommentResponse( comment.getId(), - CommentMember.from(comment.getMember()), + CommentWriterResponse.from(comment.getWriter()), comment.getContent(), comment.getCreatedAt(), comment.getUpdatedAt() ); } - - @Schema(description = "댓글 작성자 회원") - record CommentMember( - @Schema(description = "댓글 작성자 회원 ID", example = "1") - Long id, - - @Schema(description = "댓글 작성자 회원 닉네임", example = "votogether") - String nickname - ) { - - public static CommentMember from(final Member member) { - return new CommentMember(member.getId(), member.getNickname()); - } - - } - } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentWriterResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentWriterResponse.java new file mode 100644 index 000000000..1b183f16e --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/comment/CommentWriterResponse.java @@ -0,0 +1,19 @@ +package com.votogether.domain.post.dto.response.comment; + +import com.votogether.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "댓글 작성자 응답") +public record CommentWriterResponse( + @Schema(description = "댓글 작성자 ID", example = "1") + Long id, + + @Schema(description = "댓글 작성자 닉네임", example = "votogether") + String nickname +) { + + public static CommentWriterResponse from(final Member writer) { + return new CommentWriterResponse(writer.getId(), writer.getNickname()); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java index 90635c851..993aa59fb 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/CategoryResponse.java @@ -12,7 +12,7 @@ public record CategoryResponse( String name ) { - public static CategoryResponse of(Category category) { + public static CategoryResponse from(Category category) { return new CategoryResponse(category.getId(), category.getName()); } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java deleted file mode 100644 index 5ace2220e..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostDetailResponse.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.votogether.domain.post.dto.response.post; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.response.vote.VoteDetailResponse; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.PostCategories; -import com.votogether.domain.post.entity.PostCategory; -import com.votogether.domain.post.entity.PostContentImage; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import java.util.List; - -@Schema(description = "게시글 상세 정보 응답") -public record PostDetailResponse( - @Schema(description = "게시글 ID", example = "1") - Long postId, - - @Schema(description = "작성자") - WriterResponse writer, - - @Schema(description = "게시글 제목", example = "이거 한번 투표해주세요") - String title, - - @Schema(description = "게시글 내용", example = "어떤게 더 맛있나요?") - String content, - - @Schema(description = "이미지 URL", example = "http://asdasdasd.com") - String imageUrl, - - @Schema(description = "카테고리 목록", example = "[1,2]") - List categories, - - @Schema(description = "게시글 생성시각", example = "2023-08-01 13:56") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm") - LocalDateTime createdAt, - - @Schema(description = "게시글 마감기한", example = "2023-08-01 13:56") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm") - LocalDateTime deadline, - - @Schema(description = "투표 통계 정보") - VoteDetailResponse voteInfo -) { - - public static PostDetailResponse of(final Post post, final Member loginMember) { - final Member writer = post.getWriter(); - final PostBody postBody = post.getPostBody(); - final List contentImages = postBody.getPostContentImages().getContentImages(); - final StringBuilder contentImageUrl = new StringBuilder(); - - if (!contentImages.isEmpty()) { - contentImageUrl.append(contentImages.get(0).getImageUrl()); - } - - final PostCategories postCategories = post.getPostCategories(); - return new PostDetailResponse( - post.getId(), - WriterResponse.of(writer.getId(), writer.getNickname()), - postBody.getTitle(), - postBody.getContent(), - contentImageUrl.toString(), - getCategories(postCategories.getPostCategories()), - post.getCreatedAt(), - post.getDeadline(), - VoteDetailResponse.of( - post.getSelectedOptionId(loginMember), - post.getFinalTotalVoteCount(loginMember), - getOptions(post, loginMember) - ) - ); - } - - private static List getCategories(final List postCategories) { - return postCategories.stream() - .map(PostCategory::getCategory) - .map(CategoryResponse::of) - .toList(); - } - - private static List getOptions( - final Post post, - final Member loginMember - ) { - return post.getPostOptions().getPostOptions().stream() - .map(postOption -> - PostOptionDetailResponse.of( - postOption, - post.isVisibleVoteResult(loginMember), - post.getFinalTotalVoteCount(loginMember) - ) - ) - .toList(); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java deleted file mode 100644 index 8a4725bb8..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionDetailResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.votogether.domain.post.dto.response.post; - -import com.votogether.domain.post.entity.PostOption; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "게시글 선택지 정보 응답") -public record PostOptionDetailResponse( - @Schema(description = "게시글 선택지 ID", example = "1") - Long optionId, - - @Schema(description = "게시글 선택지 내용", example = "짜장면") - String content, - - @Schema(description = "이미지 URL", example = "http://sdasdas.com") - String imageUrl, - - @Schema(description = "투표 개수", example = "4") - Integer voteCount, - - @Schema(description = "투표한 비율", example = "50.0") - Double votePercent -) { - - public static PostOptionDetailResponse of( - final PostOption postOption, - final Boolean isVisibleVoteResult, - final Long totalVoteCount - ) { - return new PostOptionDetailResponse( - postOption.getId(), - postOption.getContent(), - postOption.getImageUrl(), - postOption.getVoteCount(isVisibleVoteResult), - postOption.getVotePercent(totalVoteCount) - ); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java deleted file mode 100644 index 4212cf49a..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionResponse.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.votogether.domain.post.dto.response.post; - -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostOption; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "게시글 선택지 정보 응답") -public record PostOptionResponse( - @Schema(description = "게시글 선택지 ID", example = "1") - Long optionId, - - @Schema(description = "게시글 선택지 내용", example = "짜장면") - String content, - - @Schema(description = "이미지 URL", example = "http://sdasdas.com") - String imageUrl, - - @Schema(description = "투표 개수", example = "4") - Integer voteCount, - - @Schema(description = "투표한 비율", example = "50.0") - Double votePercent -) { - - private static final int HIDDEN_COUNT = -1; - - public static PostOptionResponse of(final Post post, final PostOption postOption) { - return new PostOptionResponse( - postOption.getId(), - postOption.getContent(), - convertImageUrl(postOption.getImageUrl()), - post.isClosed() ? postOption.getVoteCount() : HIDDEN_COUNT, - postOption.getVotePercent(post.getTotalVoteCount()) - ); - } - - private static String convertImageUrl(final String imageUrl) { - return imageUrl == null ? "" : imageUrl; - } - - public static PostOptionResponse of( - final PostOption postOption, - final boolean isVisibleVoteResult, - final Long totalVoteCount - ) { - return new PostOptionResponse( - postOption.getId(), - postOption.getContent(), - convertImageUrl(postOption.getImageUrl()), - postOption.getVoteCount(isVisibleVoteResult), - postOption.getVotePercent(totalVoteCount) - ); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionVoteResultResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionVoteResultResponse.java new file mode 100644 index 000000000..1801b6d44 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostOptionVoteResultResponse.java @@ -0,0 +1,95 @@ +package com.votogether.domain.post.dto.response.post; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "투표 옵션 투표 결과 응답") +public record PostOptionVoteResultResponse( + @Schema(description = "투표 옵션 ID", example = "1") + Long optionId, + + @Schema(description = "투표 옵션 내용", example = "짜장면") + String content, + + @Schema(description = "투표 옵션 이미지 URL", example = "http://sdasdas.com") + String imageUrl, + + @Schema(description = "투표 옵션 총 투표 수", example = "4") + long voteCount, + + @Schema(description = "투표 옵션 투표 비율", example = "50.0") + double votePercent +) { + + private static final int HIDDEN_COUNT = -1; + private static final String EMPTY_IMAGE_URL = ""; + + public static PostOptionVoteResultResponse ofUser( + final Member user, + final Post post, + final PostOption postOption, + final boolean isVoted, + final long totalVoteCount + ) { + final long voteCount = countVotesForUser(user, post, postOption, isVoted); + return new PostOptionVoteResultResponse( + postOption.getId(), + postOption.getContent(), + handleEmptyImageUrl(postOption.getImageUrl()), + voteCount, + calculateVotePercent(totalVoteCount, voteCount) + ); + } + + private static long countVotesForUser( + final Member user, + final Post post, + final PostOption postOption, + final boolean isVoted + ) { + if (post.isClosed() || post.isWriter(user) || isVoted) { + return postOption.getVoteCount(); + } + return HIDDEN_COUNT; + } + + private static String handleEmptyImageUrl(final String imageUrl) { + if (imageUrl == null) { + return EMPTY_IMAGE_URL; + } + return imageUrl; + } + + private static double calculateVotePercent(final long totalCount, final long voteCount) { + if (voteCount == HIDDEN_COUNT || totalCount == 0) { + return 0.0; + } + final double votePercent = ((double) voteCount / totalCount) * 100; + return Math.round(votePercent * 10) / 10.0; + } + + public static PostOptionVoteResultResponse ofGuest( + final Post post, + final PostOption postOption, + final long totalVoteCount + ) { + final long voteCount = countVotesForGuest(post, postOption); + return new PostOptionVoteResultResponse( + postOption.getId(), + postOption.getContent(), + handleEmptyImageUrl(postOption.getImageUrl()), + voteCount, + calculateVotePercent(totalVoteCount, voteCount) + ); + } + + private static long countVotesForGuest(final Post post, final PostOption postOption) { + if (post.isClosed()) { + return postOption.getVoteCount(); + } + return HIDDEN_COUNT; + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java index d17d677d9..9a81fb8ea 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostResponse.java @@ -2,22 +2,24 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.response.vote.VoteResponse; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.domain.post.entity.PostCategory; import com.votogether.domain.post.entity.PostContentImage; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.vote.entity.Vote; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; +import java.util.Optional; -@Schema(description = "게시글에 관련한 데이터들입니다.") +@Schema(description = "게시글 응답") public record PostResponse( @Schema(description = "게시글 ID", example = "1") Long postId, - @Schema(description = "작성자") - WriterResponse writer, + @Schema(description = "게시글 작성자") + PostWriterResponse writer, @Schema(description = "게시글 제목", example = "이거 한번 투표해주세요") String title, @@ -25,100 +27,109 @@ public record PostResponse( @Schema(description = "게시글 내용", example = "어떤게 더 맛있나요?") String content, - @Schema(description = "이미지 URL", example = "http://asdasdasd.com") + @Schema(description = "게시글 이미지 URL", example = "http://asdasdasd.com") String imageUrl, - @Schema(description = "카테고리 목록", example = "[1,2]") + @Schema(description = "카테고리 목록") List categories, - @Schema(description = "게시글 생성시각", example = "2023-08-01 13:56") + @Schema(description = "게시글 생성시간", example = "2023-08-01 13:56") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime createdAt, - @Schema(description = "게시글 마감기한", example = "2023-08-01 13:56") + @Schema(description = "게시글 마감시한", example = "2023-08-01 13:56") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") LocalDateTime deadline, - @Schema(description = "투표 통계 정보") - VoteResponse voteInfo -) { + @Schema(description = "게시글 이미지 수", example = "3") + int imageCount, - public static PostResponse of(final Post post, final Member loginMember) { - final Member writer = post.getWriter(); - final PostBody postBody = post.getPostBody(); - final List contentImages = postBody.getPostContentImages().getContentImages(); - final StringBuilder contentImageUrl = new StringBuilder(); + @Schema(description = "게시글 댓글 수", example = "23") + long commentCount, - if (!contentImages.isEmpty()) { - contentImageUrl.append(contentImages.get(0).getImageUrl()); - } + @Schema(description = "게시글 투표 결과") + PostVoteResultResponse voteInfo +) { + + private static final String EMPTY_IMAGE_URL = ""; + private static final int EMPTY_POST_CONTENT_IMAGE_COUNT = 0; + private static final int EXIST_POST_CONTENT_IMAGE_COUNT = 1; + public static PostResponse ofUser( + final Member user, + final Post post, + final List postCategories, + final PostContentImage postContentImage, + final List postOptions, + final Optional vote + ) { + postOptions.sort(Comparator.comparingInt(PostOption::getSequence)); return new PostResponse( post.getId(), - WriterResponse.of(writer.getId(), writer.getNickname()), - postBody.getTitle(), - postBody.getContent(), - convertImageUrl(contentImageUrl.toString()), - getCategories(post), + PostWriterResponse.from(post.getWriter()), + post.getTitle(), + post.getContent(), + handleEmptyImageUrl(postContentImage), + convertToResponses(postCategories), post.getCreatedAt(), post.getDeadline(), - VoteResponse.of( - post.getSelectedOptionId(loginMember), - post.getFinalTotalVoteCount(loginMember), - getOptions(post, loginMember) - ) + calculateImageCount(postContentImage, postOptions), + post.getCommentCount(), + PostVoteResultResponse.ofUser(user, post, postOptions, vote) ); } - private static String convertImageUrl(final String imageUrl) { - return imageUrl == null ? "" : imageUrl; + private static String handleEmptyImageUrl(final PostContentImage postContentImage) { + if (postContentImage == null) { + return EMPTY_IMAGE_URL; + } + return postContentImage.getImageUrl(); } - private static List getCategories(final Post post) { - return post.getPostCategories() - .getPostCategories() - .stream() + private static List convertToResponses(final List postCategories) { + return postCategories.stream() .map(PostCategory::getCategory) - .map(CategoryResponse::of) + .map(CategoryResponse::from) .toList(); } - private static List getOptions( - final Post post, - final Member loginMember + private static int calculateImageCount( + final PostContentImage postContentImage, + final List postOptions ) { - return post.getPostOptions() - .getPostOptions() - .stream() - .map(postOption -> - PostOptionResponse.of( - postOption, - post.isVisibleVoteResult(loginMember), - post.getFinalTotalVoteCount(loginMember) - ) - ) - .toList(); + int count = countEmptyPostContentImage(postContentImage); + count += (int) postOptions.stream() + .filter(postOption -> postOption.getImageUrl() != null) + .count(); + return count; } - public static PostResponse forGuest(final Post post) { - final PostBody postBody = post.getPostBody(); - final List contentImages = postBody.getPostContentImages().getContentImages(); - final StringBuilder contentImageUrl = new StringBuilder(); - - if (!contentImages.isEmpty()) { - contentImageUrl.append(contentImages.get(0).getImageUrl()); + private static int countEmptyPostContentImage(final PostContentImage postContentImage) { + if (postContentImage == null) { + return EMPTY_POST_CONTENT_IMAGE_COUNT; } + return EXIST_POST_CONTENT_IMAGE_COUNT; + } + public static PostResponse ofGuest( + final Post post, + final List postCategories, + final PostContentImage postContentImage, + final List postOptions + ) { + postOptions.sort(Comparator.comparingInt(PostOption::getSequence)); return new PostResponse( post.getId(), - WriterResponse.from(post.getWriter()), - post.getPostBody().getTitle(), - post.getPostBody().getContent(), - convertImageUrl(contentImageUrl.toString()), - getCategories(post), + PostWriterResponse.from(post.getWriter()), + post.getTitle(), + post.getContent(), + handleEmptyImageUrl(postContentImage), + convertToResponses(postCategories), post.getCreatedAt(), post.getDeadline(), - VoteResponse.forGuest(post) + calculateImageCount(postContentImage, postOptions), + post.getCommentCount(), + PostVoteResultResponse.ofGuest(post, postOptions) ); } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java index f90cb208d..c4130adbe 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostSummaryResponse.java @@ -1,6 +1,7 @@ package com.votogether.domain.post.dto.response.post; import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "게시글 간략 정보 응답") @@ -23,8 +24,15 @@ public static PostSummaryResponse from(final Post post) { post.getId(), post.getWriter().getNickname(), post.getPostBody().getTitle(), - post.getTotalVoteCount() + calculateTotalVoteCount(post) ); } + private static long calculateTotalVoteCount(final Post post) { + return post.getPostOptions() + .stream() + .mapToLong(PostOption::getVoteCount) + .sum(); + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostVoteResultResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostVoteResultResponse.java new file mode 100644 index 000000000..1e18a67f3 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostVoteResultResponse.java @@ -0,0 +1,103 @@ +package com.votogether.domain.post.dto.response.post; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.vote.entity.Vote; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Optional; + +@Schema(description = "게시글 투표 결과 응답") +public record PostVoteResultResponse( + @Schema(description = "선택한 투표 옵션 ID", example = "1") + Long selectedOptionId, + + @Schema(description = "게시글 총 투표 수", example = "7") + long totalVoteCount, + + @Schema(description = "투표 옵션 투표 결과 목록") + List options +) { + + private static final int HIDDEN_COUNT = -1; + private static final Long NOT_SELECTED = 0L; + + public static PostVoteResultResponse ofUser( + final Member user, + final Post post, + final List postOptions, + final Optional vote + ) { + final long totalVoteCount = countTotalVoteCount(postOptions); + return new PostVoteResultResponse( + findSelectedOption(vote), + countVotesForUser(user, post, vote.isPresent(), totalVoteCount), + convertToUserResponses(user, post, postOptions, vote.isPresent(), totalVoteCount) + ); + } + + private static long countTotalVoteCount(final List postOptions) { + return postOptions.stream() + .mapToLong(PostOption::getVoteCount) + .sum(); + } + + private static Long findSelectedOption(final Optional vote) { + if (vote.isEmpty()) { + return NOT_SELECTED; + } + return vote.get().getPostOption().getId(); + } + + private static long countVotesForUser( + final Member user, + final Post post, + final boolean isVoted, + final long totalVoteCount + ) { + if (post.isClosed() || post.isWriter(user) || isVoted) { + return totalVoteCount; + } + return HIDDEN_COUNT; + } + + private static List convertToUserResponses( + final Member user, + final Post post, + final List postOptions, + final boolean isVoted, + final long totalVoteCount + ) { + return postOptions.stream() + .map(postOption -> PostOptionVoteResultResponse.ofUser(user, post, postOption, isVoted, totalVoteCount)) + .toList(); + } + + public static PostVoteResultResponse ofGuest(final Post post, final List postOptions) { + final long totalVoteCount = countTotalVoteCount(postOptions); + return new PostVoteResultResponse( + NOT_SELECTED, + countVotesForGuest(post, totalVoteCount), + convertToGuestResponses(post, postOptions, totalVoteCount) + ); + } + + private static long countVotesForGuest(final Post post, final long totalVoteCount) { + if (post.isClosed()) { + return totalVoteCount; + } + return HIDDEN_COUNT; + } + + private static List convertToGuestResponses( + final Post post, + final List postOptions, + final long totalVoteCount + ) { + return postOptions.stream() + .map(postOption -> PostOptionVoteResultResponse.ofGuest(post, postOption, totalVoteCount)) + .toList(); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostWriterResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostWriterResponse.java new file mode 100644 index 000000000..09e4d3730 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/post/PostWriterResponse.java @@ -0,0 +1,23 @@ +package com.votogether.domain.post.dto.response.post; + +import com.votogether.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "게시글 작성자 응답") +public record PostWriterResponse( + @Schema(description = "게시글 작성자 ID", example = "1") + Long id, + + @Schema(description = "게시글 작성자 닉네임", example = "익명의닉네임2SDSDNKLNS") + String nickname +) { + + public static PostWriterResponse from(final Member member) { + return new PostWriterResponse(member.getId(), member.getNickname()); + } + + public static PostWriterResponse of(final Long id, final String nickname) { + return new PostWriterResponse(id, nickname); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java deleted file mode 100644 index 985e4c872..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/post/WriterResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.votogether.domain.post.dto.response.post; - -import com.votogether.domain.member.entity.Member; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "작성자 응답") -public record WriterResponse( - @Schema(description = "작성자 ID", example = "1") - Long id, - - @Schema(description = "작성자 닉네임", example = "익명의닉네임2SDSDNKLNS") - String nickname -) { - - public static WriterResponse from(final Member member) { - return new WriterResponse(member.getId(), member.getNickname()); - } - - public static WriterResponse of(final Long id, final String nickname) { - return new WriterResponse(id, nickname); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java index 62be5c3c3..d8d9f8745 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteCountForAgeGroupResponse.java @@ -1,5 +1,6 @@ package com.votogether.domain.post.dto.response.vote; +import com.votogether.domain.member.entity.vo.AgeRange; import com.votogether.domain.member.entity.vo.Gender; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; @@ -18,12 +19,13 @@ public record VoteCountForAgeGroupResponse( @Schema(description = "여자 투표 수", example = "7") int femaleCount ) { - public static VoteCountForAgeGroupResponse of(final String ageGroup, final Map genderGroup) { + + public static VoteCountForAgeGroupResponse of(final AgeRange ageRange, final Map genderGroup) { final int maleCount = genderGroup.getOrDefault(Gender.MALE, 0L).intValue(); final int femaleCount = genderGroup.getOrDefault(Gender.FEMALE, 0L).intValue(); final int voteCount = maleCount + femaleCount; - return new VoteCountForAgeGroupResponse(ageGroup, voteCount, maleCount, femaleCount); + return new VoteCountForAgeGroupResponse(ageRange.getName(), voteCount, maleCount, femaleCount); } } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java deleted file mode 100644 index 0eabf9522..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteDetailResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.votogether.domain.post.dto.response.vote; - -import com.votogether.domain.post.dto.response.post.PostOptionDetailResponse; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -@Schema(description = "투표 상세 응답") -public record VoteDetailResponse( - @Schema(description = "선택지 ID", example = "1") - Long selectedOptionId, - - @Schema(description = "총 투표 수", example = "2") - Long totalVoteCount, - - @Schema(description = "선택지 상세 응답") - List options -) { - - public static VoteDetailResponse of( - final long selectedOptionId, - final long finalTotalVoteCount, - final List options - ) { - return new VoteDetailResponse(selectedOptionId, finalTotalVoteCount, options); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java index 1b794e7f7..91c216667 100644 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java +++ b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteOptionStatisticsResponse.java @@ -2,12 +2,14 @@ import com.votogether.domain.member.entity.vo.AgeRange; import com.votogether.domain.member.entity.vo.Gender; +import com.votogether.domain.vote.repository.dto.VoteCountByAgeGroupAndGenderDto; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Arrays; -import java.util.HashMap; +import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; - +import java.util.stream.Collectors; @Schema(description = "투표 선택지 통계 응답") public record VoteOptionStatisticsResponse( @@ -24,13 +26,13 @@ public record VoteOptionStatisticsResponse( List ageGroup ) { - public static VoteOptionStatisticsResponse from(final Map> voteStatusGroup) { + public static VoteOptionStatisticsResponse from(final List voteCounts) { + final Map> ageRangeGroup = formAgeRangeGroup(voteCounts); final List ageGroupStatistics = Arrays.stream(AgeRange.values()) - .map(AgeRange::getName) - .map(ageName -> + .map(ageRange -> VoteCountForAgeGroupResponse.of( - ageName, - voteStatusGroup.computeIfAbsent(ageName, ignore -> new HashMap<>()) + ageRange, + ageRangeGroup.computeIfAbsent(ageRange, ignore -> new EnumMap<>(Gender.class)) ) ) .toList(); @@ -43,4 +45,20 @@ public static VoteOptionStatisticsResponse from(final Map> formAgeRangeGroup( + final List voteCounts + ) { + return voteCounts.stream() + .collect(Collectors.groupingBy( + voteCount -> AgeRange.from(voteCount.ageGroup()), + LinkedHashMap::new, + Collectors.toMap( + VoteCountByAgeGroupAndGenderDto::gender, + VoteCountByAgeGroupAndGenderDto::voteCount, + (exist, replace) -> replace, + () -> new EnumMap<>(Gender.class) + ) + )); + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java b/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java deleted file mode 100644 index fac8d92de..000000000 --- a/backend/src/main/java/com/votogether/domain/post/dto/response/vote/VoteResponse.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.votogether.domain.post.dto.response.vote; - -import com.votogether.domain.post.dto.response.post.PostOptionResponse; -import com.votogether.domain.post.entity.Post; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - - -@Schema(description = "투표 응답") -public record VoteResponse( - @Schema(description = "선택지 ID", example = "1") - long selectedOptionId, - - @Schema(description = "총 투표 수", example = "7") - long totalVoteCount, - - @Schema(description = "선택지 옵션 응답") - List options -) { - - private static final int NOT_SELECTED = 0; - private static final int HIDDEN_COUNT = -1; - - public static VoteResponse of( - final long selectedOptionId, - final long finalTotalVoteCount, - final List options - ) { - return new VoteResponse(selectedOptionId, finalTotalVoteCount, options); - } - - public static VoteResponse forGuest(final Post post) { - return new VoteResponse( - NOT_SELECTED, - post.isClosed() ? post.getTotalVoteCount() : HIDDEN_COUNT, - listOfOptionsForGuest(post) - ); - } - - private static List listOfOptionsForGuest(final Post post) { - return post.getPostOptions().getPostOptions() - .stream() - .map(postOption -> PostOptionResponse.of(post, postOption)) - .toList(); - } - - @Override - public String toString() { - return "VoteInfoResponse{" + - "selectedOptionId=" + selectedOptionId + - ", totalVoteCount=" + totalVoteCount + - ", options=" + options + - '}'; - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/Post.java b/backend/src/main/java/com/votogether/domain/post/entity/Post.java index 5e293b334..23db98174 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/Post.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/Post.java @@ -5,10 +5,10 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.comment.Comment; import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.domain.report.exception.ReportExceptionType; +import com.votogether.domain.post.exception.PostOptionExceptionType; import com.votogether.domain.vote.entity.Vote; +import com.votogether.domain.vote.exception.VoteExceptionType; import com.votogether.global.exception.BadRequestException; -import jakarta.persistence.Basic; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -24,18 +24,22 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.stream.IntStream; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.Formula; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity +@EqualsAndHashCode(of = {"id"}, callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Post extends BaseEntity { + private static final int MAXIMUM_POST_OPTION_SIZE = 5; + private static final long DELETE_VOTE_LIMIT = 20; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -48,22 +52,22 @@ public class Post extends BaseEntity { private PostBody postBody; @Embedded - private PostCategories postCategories; - - @Embedded - private PostOptions postOptions; - - @Column(columnDefinition = "datetime(6)", nullable = false) - private LocalDateTime deadline; + private PostDeadline postDeadline; @Column(nullable = false) private boolean isHidden; - @Basic(fetch = FetchType.LAZY) - @Formula("(select count(v.id) from Vote v where v.post_option_id in " - + "(select po.id from Post_Option po where po.post_id = id)" - + ")") - private long totalVoteCount; + @Formula("(select count(*) from comment c where c.post_id = id)") + private long commentCount; + + @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) + private List postCategories = new ArrayList<>(); + + @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) + private List postContentImages = new ArrayList<>(); + + @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) + private List postOptions = new ArrayList<>(); @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) private List comments = new ArrayList<>(); @@ -71,43 +75,57 @@ public class Post extends BaseEntity { @Builder private Post( final Member writer, - final PostBody postBody, + final String title, + final String content, final LocalDateTime deadline ) { this.writer = writer; - this.postBody = postBody; - this.deadline = deadline; - this.postCategories = new PostCategories(); - this.postOptions = new PostOptions(); + this.postBody = new PostBody(title, content); + this.postDeadline = new PostDeadline(deadline); this.isHidden = false; } - public void mapCategories(final List categories) { - this.postCategories.mapPostAndCategories(this, categories); + public void addCategory(final Category category) { + final PostCategory postCategory = PostCategory.builder() + .post(this) + .category(category) + .build(); + this.postCategories.add(postCategory); } - public void mapPostOptionsByElements( - final List postOptionContents, - final List optionImageUrls - ) { - this.postOptions = PostOptions.of(this, postOptionContents, optionImageUrls); + public void addContentImage(final String path) { + final PostContentImage postContentImage = PostContentImage.builder() + .post(this) + .imageUrl(path) + .build(); + this.postContentImages.add(postContentImage); } - public void validateDeadlineNotExceedByMaximumDeadline(final int maximumDeadline) { - LocalDateTime maximumDeadlineFromNow = LocalDateTime.now().plusDays(maximumDeadline); - if (this.deadline.isAfter(maximumDeadlineFromNow)) { - throw new BadRequestException(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS); - } + public void addPostOption(final PostOption postOption) { + postOption.setPost(this); + this.postOptions.add(postOption); } - public void validateWriter(final Member member) { - if (!Objects.equals(this.writer, member)) { - throw new BadRequestException(PostExceptionType.NOT_WRITER); - } + public void addComment(final Comment comment) { + comment.setPost(this); + this.comments.add(comment); + } + + public void removePostContentImage(final PostContentImage postContentImage) { + this.postContentImages.remove(postContentImage); + } + + public void removePostOption(final PostOption deletedOption) { + this.postOptions.remove(deletedOption); + } + + public void update(final String title, final String content, final LocalDateTime deadline) { + this.postBody = new PostBody(title, content); + this.postDeadline = new PostDeadline(deadline); } - public long getSelectedOptionId(final Member member) { - return this.postOptions.getSelectedOptionId(member); + public void closeEarly() { + this.postDeadline.close(); } public Vote makeVote(final Member voter, final PostOption postOption) { @@ -125,149 +143,72 @@ public Vote makeVote(final Member voter, final PostOption postOption) { public void validateDeadLine() { if (isClosed()) { - throw new BadRequestException(PostExceptionType.POST_CLOSED); + throw new BadRequestException(PostExceptionType.CLOSED); } } - public boolean isClosed() { - return deadline.isBefore(LocalDateTime.now()); - } - private void validateVoter(final Member voter) { if (Objects.equals(this.writer.getId(), voter.getId())) { - throw new BadRequestException(PostExceptionType.NOT_VOTER); + throw new BadRequestException(VoteExceptionType.CANNOT_VOTE_MY_POST); } } private void validatePostOption(final PostOption postOption) { if (!hasPostOption(postOption)) { - throw new BadRequestException(PostExceptionType.POST_OPTION_NOT_FOUND); + throw new BadRequestException(PostOptionExceptionType.NOT_FOUND); } } private boolean hasPostOption(final PostOption postOption) { - return postOptions.contains(postOption); + return this.postOptions.contains(postOption); } - public void closeEarly() { - this.deadline = LocalDateTime.now(); - } - - public void addContentImage(final String contentImageUrl) { - this.postBody.addContentImage(this, contentImageUrl); + public boolean canDelete() { + final long totalVoteCount = this.postOptions.stream() + .mapToLong(PostOption::getVoteCount) + .sum(); + return totalVoteCount < DELETE_VOTE_LIMIT; } - public long getFinalTotalVoteCount(final Member loginMember) { - if (isVisibleVoteResult(loginMember)) { - return this.totalVoteCount; - } - - return -1L; + public boolean isWriter(final Member member) { + return Objects.equals(this.writer, member); } - public boolean isVisibleVoteResult(final Member member) { - return this.postOptions.getSelectedOptionId(member) != 0 - || this.writer.equals(member) - || isClosed(); + public boolean isClosed() { + return this.postDeadline.isClosed(); } public void blind() { this.isHidden = true; } - public void validateMine(final Member member) { - if (this.writer.equals(member)) { - throw new BadRequestException(ReportExceptionType.REPORT_MY_POST); - } + public boolean isLimitOptionSize(final int size) { + return size <= MAXIMUM_POST_OPTION_SIZE; } - public void validateHidden() { - if (this.isHidden) { - throw new BadRequestException(ReportExceptionType.ALREADY_HIDDEN_POST); - } + public String getTitle() { + return this.postBody.getTitle(); } - public void addComment(final Comment comment) { - comments.add(comment); - comment.setPost(this); + public String getContent() { + return this.postBody.getContent(); } - public void validatePossibleToDelete() { - if (this.totalVoteCount >= 20) { - throw new BadRequestException(PostExceptionType.CANNOT_DELETE_BECAUSE_MORE_THAN_TWENTY_VOTES); - } - } - - public void update( - final PostBody postBody, - final String oldContentImageUrl, - final List contentImageUrls, - final List categories, - final List postOptionContents, - final List oldPostOptionImageUrls, - final List postOptionImageUrls, - final LocalDateTime deadline - ) { - this.postBody.update(postBody, oldContentImageUrl, contentImageUrls); - this.postCategories.update(this, categories); - addAllPostOptions(postOptionContents, oldPostOptionImageUrls, postOptionImageUrls); - this.deadline = deadline; + public LocalDateTime getDeadline() { + return this.postDeadline.getDeadline(); } - private void addAllPostOptions( - final List postOptionContents, - final List oldPostOptionImageUrls, - final List postOptionImageUrls - ) { - this.postOptions.addAll( - this, - postOptionContents, - getPostOptionImageUrls(oldPostOptionImageUrls, postOptionImageUrls) - ); - } - - public void postOptionsClear() { - this.postOptions.clear(); - } - - private List getPostOptionImageUrls( - final List oldPostOptionImageUrls, - final List postOptionImageUrls - ) { - return IntStream.range(0, postOptionImageUrls.size()) - .mapToObj(postOptionIndex -> - getPostOptionImageUrl( - postOptionIndex, - oldPostOptionImageUrls, - postOptionImageUrls - ) - ) - .toList(); - } - - private String getPostOptionImageUrl( - final int postOptionIndex, - final List oldPostOptionImageUrls, - final List postOptionImageUrls - ) { - final String postOptionImageUrl = postOptionImageUrls.get(postOptionIndex); - if (postOptionImageUrl.isEmpty()) { - return oldPostOptionImageUrls.get(postOptionIndex); + public PostContentImage getFirstContentImage() { + if (this.postContentImages.isEmpty()) { + return null; } - - return postOptionImageUrls.get(postOptionIndex); + return postContentImages.get(0); } - public void validateDeadLineToModify(final LocalDateTime deadlineToModify) { - if (getCreatedAt().plusDays(3).isBefore(deadlineToModify)) { - throw new BadRequestException(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS); - } - } - - public void validateExistVote() { - if (totalVoteCount > 0) { - throw new BadRequestException(PostExceptionType.VOTING_PROGRESS_NOT_EDITABLE); - } + public long getTotalVoteCount() { + return postOptions.stream() + .mapToLong(PostOption::getVoteCount) + .sum(); } } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java b/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java index 9e5e00217..af21d2862 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostBody.java @@ -1,58 +1,51 @@ package com.votogether.domain.post.entity; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.global.exception.BadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import jakarta.persistence.Embedded; -import java.util.List; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostBody { - @Column(length = 100, nullable = false) + private static final int MAXIMUM_TITLE_LENGTH = 100; + private static final int MAXIMUM_CONTENT_LENGTH = 1000; + + @Column(length = MAXIMUM_TITLE_LENGTH, nullable = false) private String title; - @Column(length = 1000, nullable = false) + @Column(length = MAXIMUM_CONTENT_LENGTH, nullable = false) private String content; - @Embedded - private PostContentImages postContentImages; - - @Builder - private PostBody(final String title, final String content) { + public PostBody(final String title, final String content) { + validateTitle(title); + validateContent(content); this.title = title; this.content = content; - this.postContentImages = new PostContentImages(); - } - - public void update( - final PostBody postBody, - final String oldContentImageUrl, - final List contentImageUrls - ) { - this.title = postBody.getTitle(); - this.content = postBody.getContent(); - this.postContentImages.update(getContentImageUrl(oldContentImageUrl, contentImageUrls)); } - private String getContentImageUrl( - final String oldContentImageUrl, - final List contentImageUrls - ) { - if (contentImageUrls.isEmpty()) { - return oldContentImageUrl; + private void validateTitle(final String title) { + if (!StringUtils.hasText(title)) { + throw new BadRequestException(PostExceptionType.TITLE_EMPTY); + } + if (title.length() > MAXIMUM_TITLE_LENGTH) { + throw new BadRequestException(PostExceptionType.TITLE_INVALID_LENGTH); } - - return contentImageUrls.get(0); } - public void addContentImage(final Post post, final String contentImageUrl) { - this.postContentImages.addContentImage(post, contentImageUrl); + private void validateContent(final String content) { + if (!StringUtils.hasText(content)) { + throw new BadRequestException(PostExceptionType.CONTENT_EMPTY); + } + if (content.length() > MAXIMUM_CONTENT_LENGTH) { + throw new BadRequestException(PostExceptionType.CONTENT_INVALID_LENGTH); + } } } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java b/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java deleted file mode 100644 index 9f43068bb..000000000 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostCategories.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.votogether.domain.post.entity; - -import com.votogether.domain.category.entity.Category; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Embeddable; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Predicate; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -@Embeddable -public class PostCategories { - - @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) - private List postCategories = new ArrayList<>(); - - public void mapPostAndCategories(final Post post, final List categories) { - categories.forEach(category -> postCategories.add(createPostCategory(post, category))); - } - - private PostCategory createPostCategory(final Post post, final Category category) { - return PostCategory.builder() - .post(post) - .category(category) - .build(); - } - - public void update(final Post post, final List categories) { - postCategories.removeIf(Predicate.not(postCategory -> categories.contains(postCategory.getCategory()))); - - categories.stream() - .filter(this::isCategoryNotPresent) - .forEach(category -> this.postCategories.add(createPostCategory(post, category))); - } - - private boolean isCategoryNotPresent(Category category) { - return this.postCategories.stream() - .noneMatch(postCategory -> postCategory.getCategory().equals(category)); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java b/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java index ef562207c..56fb9902b 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostCategory.java @@ -13,13 +13,15 @@ import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; -@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "category_id"})}) -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity +@EqualsAndHashCode(of = {"id"}, callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "category_id"})}) public class PostCategory extends BaseEntity { @Id @@ -40,4 +42,8 @@ private PostCategory(final Post post, final Category category) { this.category = category; } + public void updateCategory(final Category category) { + this.category = category; + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java index d6e5307b2..fb21b834f 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImage.java @@ -14,9 +14,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostContentImage extends BaseEntity { @Id @@ -31,7 +31,7 @@ public class PostContentImage extends BaseEntity { private String imageUrl; @Builder - public PostContentImage(final Post post, final String imageUrl) { + private PostContentImage(final Post post, final String imageUrl) { this.post = post; this.imageUrl = imageUrl; } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java b/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java deleted file mode 100644 index 40701594f..000000000 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostContentImages.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.votogether.domain.post.entity; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Embeddable; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -@Embeddable -public class PostContentImages { - - @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) - private List contentImages = new ArrayList<>(); - - public void addContentImage(final Post post, final String contentImageUrl) { - this.contentImages.add(getPostContentImage(post, contentImageUrl)); - } - - private PostContentImage getPostContentImage(final Post post, final String contentImageUrl) { - return PostContentImage.builder() - .post(post) - .imageUrl(contentImageUrl) - .build(); - } - - public void update(final String imageUrl) { - this.contentImages.get(0).updateImageUrl(imageUrl); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostDeadline.java b/backend/src/main/java/com/votogether/domain/post/entity/PostDeadline.java new file mode 100644 index 000000000..92528ca2e --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostDeadline.java @@ -0,0 +1,45 @@ +package com.votogether.domain.post.entity; + +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.global.exception.BadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostDeadline { + + private static final long MAXIMUM_DEADLINE = 14; + + @Column(columnDefinition = "datetime(6)", nullable = false) + private LocalDateTime deadline; + + public PostDeadline(final LocalDateTime deadline) { + validate(deadline); + this.deadline = deadline; + } + + private void validate(final LocalDateTime deadline) { + final LocalDate now = LocalDateTime.now().toLocalDate(); + final LocalDate deadlineDate = deadline.toLocalDate(); + if (ChronoUnit.DAYS.between(now, deadlineDate) > MAXIMUM_DEADLINE) { + throw new BadRequestException(PostExceptionType.DEADLINE_EXCEED); + } + } + + public void close() { + this.deadline = LocalDateTime.now(); + } + + public boolean isClosed() { + return this.deadline.isBefore(LocalDateTime.now()); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java b/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java index 8849c2e3f..d97a1cd91 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostOption.java @@ -1,10 +1,10 @@ package com.votogether.domain.post.entity; import com.votogether.domain.common.BaseEntity; -import com.votogether.domain.member.entity.Member; import com.votogether.domain.vote.entity.Vote; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -20,32 +20,41 @@ import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Formula; -@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "sequence"})}) -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity +@EqualsAndHashCode(of = {"id"}, callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "sequence"})}) public class PostOption extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Setter @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) private Post post; + @Setter @Column(nullable = false) - private int sequence; + private Integer sequence; - @Column(length = 50, nullable = false) - private String content; + @Embedded + private PostOptionBody postOptionBody; @Column private String imageUrl; + @Formula("(select count(*) from vote v where v.post_option_id = id)") + private long voteCount; + @OneToMany(mappedBy = "postOption", cascade = CascadeType.PERSIST, orphanRemoval = true) private List votes = new ArrayList<>(); @@ -58,78 +67,26 @@ private PostOption( ) { this.post = post; this.sequence = sequence; - this.content = content; + this.postOptionBody = new PostOptionBody(content); this.imageUrl = imageUrl; } - public static PostOption of( - final String postOptionContent, - final Post post, - final int postOptionSequence, - final String optionImageUrl - ) { - if (!optionImageUrl.isEmpty()) { - return toPostOptionEntity(post, postOptionSequence, postOptionContent, optionImageUrl); - } - - return toPostOptionEntity(post, postOptionSequence, postOptionContent, ""); - } - - private static PostOption toPostOptionEntity( - final Post post, - final Integer postOptionSequence, - final String postOptionContent, - final String optionImageUrl - ) { - return PostOption.builder() - .post(post) - .sequence(postOptionSequence) - .content(postOptionContent) - .imageUrl(optionImageUrl) - .build(); - } - public void addVote(final Vote vote) { - this.votes.add(vote); vote.setPostOption(this); + this.votes.add(vote); } - public boolean hasMemberVote(final Member member) { - return votes.stream() - .anyMatch(vote -> vote.isVoteByMember(member)); + public void update(final String content, final String imageUrl) { + this.postOptionBody = new PostOptionBody(content); + this.imageUrl = imageUrl; } - public boolean isBelongsTo(final Post post) { + public boolean belongsTo(final Post post) { return Objects.equals(this.post.getId(), post.getId()); } - public int getVoteCount(final boolean isPostVoteByMember) { - final int votesCount = votes.size(); - if (isPostVoteByMember) { - return votesCount; - } - - return -1; - } - - public double getVotePercent(final long totalVoteCount) { - if (isPostVoteByMember(totalVoteCount)) { - return calculateVotePercent(totalVoteCount); - } - - return totalVoteCount; - } - - private boolean isPostVoteByMember(final long totalVoteCount) { - return totalVoteCount > 0; - } - - private double calculateVotePercent(final Long totalVoteCount) { - return ((double) this.votes.size() / totalVoteCount) * 100; - } - - public int getVoteCount() { - return this.votes.size(); + public String getContent() { + return this.postOptionBody.getContent(); } } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostOptionBody.java b/backend/src/main/java/com/votogether/domain/post/entity/PostOptionBody.java new file mode 100644 index 000000000..8f68b94b4 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/entity/PostOptionBody.java @@ -0,0 +1,36 @@ +package com.votogether.domain.post.entity; + +import com.votogether.domain.post.exception.PostOptionExceptionType; +import com.votogether.global.exception.BadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostOptionBody { + + private static final int MAXIMUM_CONTENT_LENGTH = 50; + + @Column(length = MAXIMUM_CONTENT_LENGTH, nullable = false) + private String content; + + public PostOptionBody(final String content) { + validate(content); + this.content = content; + } + + private void validate(final String content) { + if (!StringUtils.hasText(content)) { + throw new BadRequestException(PostOptionExceptionType.CONTENT_EMPTY); + } + if (content.length() > MAXIMUM_CONTENT_LENGTH) { + throw new BadRequestException(PostOptionExceptionType.CONTENT_INVALID_LENGTH); + } + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java b/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java deleted file mode 100644 index fb19477e6..000000000 --- a/backend/src/main/java/com/votogether/domain/post/entity/PostOptions.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.votogether.domain.post.entity; - -import com.votogether.domain.member.entity.Member; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Embeddable; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.IntStream; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@Getter -@Embeddable -public class PostOptions { - - private static final Integer FIRST_OPTION_SEQUENCE = 1; - - @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) - private List postOptions = new ArrayList<>(); - - public static PostOptions of( - final Post post, - final List postOptionContents, - final List optionImageUrls - ) { - final PostOptions newInstance = new PostOptions(); - final List postOptions = getPostOptions(post, postOptionContents, optionImageUrls); - - newInstance.postOptions.addAll(postOptions); - return newInstance; - } - - private static List getPostOptions( - final Post post, - final List postOptionContents, - final List optionImageUrls - ) { - return IntStream.rangeClosed(FIRST_OPTION_SEQUENCE, postOptionContents.size()) - .mapToObj(postOptionSequence -> - toPostOption(post, postOptionContents, optionImageUrls, postOptionSequence) - ) - .toList(); - } - - private static PostOption toPostOption( - final Post post, - final List postOptionContents, - final List optionImageUrls, - final int postOptionSequence - ) { - return PostOption.of( - postOptionContents.get(postOptionSequence - 1), - post, - postOptionSequence, - optionImageUrls.get(postOptionSequence - 1) - ); - } - - public Boolean contains(final PostOption postOption) { - return postOptions.contains(postOption); - } - - public Long getSelectedOptionId(final Member member) { - return postOptions.stream() - .filter(postOption -> postOption.hasMemberVote(member)) - .findAny() - .map(PostOption::getId) - .orElse(0L); - } - - public void addAll( - final Post post, - final List postOptionContents, - final List postOptionImageUrls - ) { - this.postOptions.addAll(getPostOptions(post, postOptionContents, postOptionImageUrls)); - } - - public void clear() { - this.postOptions.clear(); - } - -} diff --git a/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java index 8f9250848..b58dd2722 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/comment/Comment.java @@ -3,9 +3,6 @@ import com.votogether.domain.common.BaseEntity; import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.exception.CommentExceptionType; -import com.votogether.domain.report.exception.ReportExceptionType; -import com.votogether.global.exception.BadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -18,13 +15,15 @@ import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = {"id"}, callSuper = false) public class Comment extends BaseEntity { @Id @@ -38,7 +37,7 @@ public class Comment extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) - private Member member; + private Member writer; @Embedded private Content content; @@ -49,47 +48,31 @@ public class Comment extends BaseEntity { @Builder private Comment( final Post post, - final Member member, + final Member writer, final String content ) { this.post = post; - this.member = member; + this.writer = writer; this.content = new Content(content); this.isHidden = false; } - public void validateWriter(final Member member) { - if (!Objects.equals(this.member.getId(), member.getId())) { - throw new BadRequestException(CommentExceptionType.NOT_WRITER); - } - } - - public void validateBelong(final Post post) { - if (!Objects.equals(this.post.getId(), post.getId())) { - throw new BadRequestException(CommentExceptionType.NOT_BELONG_POST); - } - } - - public void blind() { - this.isHidden = true; - } - - public void validateMine(final Member reporter) { - if (this.member.equals(reporter)) { - throw new BadRequestException(ReportExceptionType.REPORT_MY_COMMENT); - } + public boolean belongsTo(final Post post) { + return Objects.equals(this.post, post); } - public void validateHidden() { - if (this.isHidden) { - throw new BadRequestException(ReportExceptionType.ALREADY_HIDDEN_COMMENT); - } + public boolean isWriter(final Member member) { + return Objects.equals(this.writer, member); } public void updateContent(final String content) { this.content = new Content(content); } + public void blind() { + this.isHidden = true; + } + public String getContent() { return content.getValue(); } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java b/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java index e6e0fd3b8..5691941da 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/comment/Content.java @@ -8,10 +8,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Embeddable -class Content { +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Content { private static final int MAXIMUM_LENGTH = 500; @@ -25,7 +25,7 @@ public Content(final String value) { private void validate(final String content) { if (content.length() > MAXIMUM_LENGTH) { - throw new BadRequestException(CommentExceptionType.INVALID_CONTENT_LENGTH); + throw new BadRequestException(CommentExceptionType.INVALID_LENGTH); } } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java index d98d43b4a..d15f3c68c 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostClosingType.java @@ -4,6 +4,7 @@ public enum PostClosingType { ALL, PROGRESS, - CLOSED + CLOSED, + ; } diff --git a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java index 97abc0cf5..299563463 100644 --- a/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java +++ b/backend/src/main/java/com/votogether/domain/post/entity/vo/PostSortType.java @@ -1,28 +1,9 @@ package com.votogether.domain.post.entity.vo; -import lombok.Getter; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; - -@Getter public enum PostSortType { - LATEST( - Sort.by(Direction.DESC, "createdAt"), - Sort.by(Direction.DESC, "postOption.post.createdAt") - ), - - HOT( - Sort.by(Direction.DESC, "totalVoteCount"), - Sort.by(Direction.DESC, "postOption.post.totalVoteCount") - ); - - private final Sort postBaseSort; - private final Sort voteBaseSort; - - PostSortType(final Sort postBaseSort, final Sort voteBaseSort) { - this.postBaseSort = postBaseSort; - this.voteBaseSort = voteBaseSort; - } + LATEST, + HOT, + ; } diff --git a/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java index de350346f..72e4795ff 100644 --- a/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java +++ b/backend/src/main/java/com/votogether/domain/post/exception/CommentExceptionType.java @@ -6,10 +6,13 @@ @Getter public enum CommentExceptionType implements ExceptionType { - INVALID_CONTENT_LENGTH(2000, "유효하지 않은 댓글 길이입니다."), - COMMENT_NOT_FOUND(2001, "해당 댓글이 존재하지 않습니다."), - NOT_BELONG_POST(2002, "댓글의 게시글 정보와 일치하지 않습니다."), - NOT_WRITER(2003, "댓글 작성자가 아닙니다."); + INVALID_LENGTH(600, "유효하지 않은 댓글 길이입니다."), + NOT_FOUND(601, "댓글이 존재하지 않습니다."), + NOT_BELONG_POST(602, "게시글의 댓글이 아닙니다."), + NOT_WRITER(603, "댓글 작성자가 아닙니다."), + IS_HIDDEN(604, "신고에 의해 숨겨진 댓글은 접근할 수 없습니다."), + REPORT_MINE(605, "본인 댓글은 신고할 수 없습니다."), + ; private final int code; private final String message; diff --git a/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java index 7e09bd98e..884538592 100644 --- a/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java +++ b/backend/src/main/java/com/votogether/domain/post/exception/PostExceptionType.java @@ -6,17 +6,20 @@ @Getter public enum PostExceptionType implements ExceptionType { - POST_NOT_FOUND(1000, "해당 게시글이 존재하지 않습니다."), - POST_OPTION_NOT_FOUND(1001, "해당 게시글 투표 옵션이 존재하지 않습니다."), - UNRELATED_POST_OPTION(1002, "게시글 투표 옵션이 게시글과 연관되어 있지 않습니다."), - NOT_WRITER(1003, "해당 게시글 작성자가 아닙니다."), - POST_CLOSED(1004, "게시글이 이미 마감되었습니다."), - POST_NOT_HALF_DEADLINE(1005, "게시글이 마감 시간까지 절반의 시간 이상이 지나지 않으면 조기마감을 할 수 없습니다."), - NOT_VOTER(1004, "해당 게시글 작성자는 투표할 수 없습니다."), - DEADLINE_EXCEED_THREE_DAYS(1005, "마감 기한은 생성 시간으로부터 3일을 초과할 수 없습니다."), - WRONG_IMAGE(1006, "이미지 저장에 실패했습니다. 다시 시도해주세요."), - CANNOT_DELETE_BECAUSE_MORE_THAN_TWENTY_VOTES(1007, "투표가 20개 이상이므로 해당 게시글을 삭제할 수 없습니다."), - VOTING_PROGRESS_NOT_EDITABLE(1008, "해당 게시글은 투표가 진행되어 수정할 수 없습니다."); + NOT_FOUND(500, "게시글이 존재하지 않습니다."), + NOT_WRITER(501, "게시글 작성자가 아닙니다."), + CLOSED(502, "게시글이 마감되었습니다."), + DEADLINE_EXCEED(503, "최대 마감기간을 초과하였습니다."), + WRONG_IMAGE(504, "이미지 저장에 실패했습니다. 다시 시도해주세요."), + FAIL_DELETE_EXCEED(505, "일정 투표 수 이상의 게시글은 삭제할 수 없습니다."), + FAIL_UPDATE_VOTED_POST(506, "투표된 게시글은 수정할 수 없습니다."), + TITLE_EMPTY(507, "게시글 제목은 비어있거나 공백일 수 없습니다."), + CONTENT_EMPTY(508, "게시글 내용은 비어있거나 공백일 수 없습니다."), + TITLE_INVALID_LENGTH(509, "게시글 제목 길이가 유효하지 않습니다."), + CONTENT_INVALID_LENGTH(510, "게시글 내용 길이가 유효하지 않습니다."), + IS_HIDDEN(511, "신고에 의해 숨겨진 게시글은 접근할 수 없습니다."), + REPORT_MINE(512, "본인 게시글은 신고할 수 없습니다."), + ; private final int code; private final String message; diff --git a/backend/src/main/java/com/votogether/domain/post/exception/PostOptionExceptionType.java b/backend/src/main/java/com/votogether/domain/post/exception/PostOptionExceptionType.java new file mode 100644 index 000000000..5e0045258 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/exception/PostOptionExceptionType.java @@ -0,0 +1,25 @@ +package com.votogether.domain.post.exception; + +import com.votogether.global.exception.ExceptionType; +import lombok.Getter; + +@Getter +public enum PostOptionExceptionType implements ExceptionType { + + NOT_FOUND(500, "게시글 투표 옵션이 존재하지 않습니다."), + UNRELATED_POST(501, "게시글의 투표 옵션이 아닙니다."), + CONTENT_EMPTY(502, "게시글 옵션 내용은 비어있거나 공백일 수 없습니다."), + CONTENT_INVALID_LENGTH(503, "게시글 옵션 내용 길이가 유효하지 않습니다."), + SIZE_EXCEED(504, "최대 게시글 옵션 개수를 초과하였습니다."), + DUPLICATE_UPDATE(505, "게시글 옵션을 중복해서 수정할 수 없습니다."), + ; + + private final int code; + private final String message; + + PostOptionExceptionType(final int code, final String message) { + this.code = code; + this.message = message; + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java index e4010b24b..2d28a9794 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/CommentRepository.java @@ -6,12 +6,19 @@ import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface CommentRepository extends JpaRepository { - @EntityGraph(attributePaths = {"member"}) + @EntityGraph(attributePaths = {"writer"}) List findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(final Post post); - List findAllByMember(final Member member); + List findAllByWriter(final Member writer); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from Comment c where c.post.id = :postId") + void deleteAllWithPostIdInBatch(@Param("postId") final Long postId); } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java index d6e61a244..1e7cc6f42 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCategoryRepository.java @@ -1,7 +1,21 @@ package com.votogether.domain.post.repository; +import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.PostCategory; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostCategoryRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"category"}) + List findAllByPost(final Post post); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from PostCategory p where p.post.id = :postId") + void deleteAllWithPostIdInBatch(@Param("postId") final Long postId); + } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java index 32bb8f3f8..54d466e9d 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostContentImageRepository.java @@ -2,6 +2,14 @@ import com.votogether.domain.post.entity.PostContentImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostContentImageRepository extends JpaRepository { + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from PostContentImage p where p.post.id = :postId") + void deleteAllWithPostIdInBatch(@Param("postId") final Long postId); + } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java index 18d0f022e..015230e29 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepository.java @@ -9,26 +9,31 @@ public interface PostCustomRepository { - List findAllByClosingTypeAndSortTypeAndCategoryId( + List findPostsWithFilteringAndPaging( final PostClosingType postClosingType, final PostSortType postSortType, final Long categoryId, final Pageable pageable ); - List findAllWithKeyword( + List findPostsByWriterWithFilteringAndPaging( + final Member writer, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Pageable pageable + ); + + List findSearchPostsWithFilteringAndPaging( final String keyword, final PostClosingType postClosingType, final PostSortType postSortType, - final Long categoryId, final Pageable pageable ); - List findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - final Member writer, + List findPostsByVotedWithFilteringAndPaging( + final Member voter, final PostClosingType postClosingType, final PostSortType postSortType, - final Long categoryId, final Pageable pageable ); diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java index e271c4932..d69c64a67 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostCustomRepositoryImpl.java @@ -2,9 +2,13 @@ import static com.votogether.domain.post.entity.QPost.post; import static com.votogether.domain.post.entity.QPostCategory.postCategory; +import static com.votogether.domain.post.entity.QPostOption.postOption; +import static com.votogether.domain.vote.entity.QVote.vote; +import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; @@ -24,17 +28,17 @@ public class PostCustomRepositoryImpl implements PostCustomRepository { private final JPAQueryFactory jpaQueryFactory; @Override - public List findAllByClosingTypeAndSortTypeAndCategoryId( + public List findPostsWithFilteringAndPaging( final PostClosingType postClosingType, final PostSortType postSortType, final Long categoryId, final Pageable pageable ) { return jpaQueryFactory - .selectFrom(post) - .distinct() + .selectDistinct(post) + .from(post) .join(post.writer).fetchJoin() - .leftJoin(post.postCategories.postCategories, postCategory) + .leftJoin(post.postCategories, postCategory) .where( categoryIdEq(categoryId), deadlineEq(postClosingType), @@ -47,20 +51,19 @@ public List findAllByClosingTypeAndSortTypeAndCategoryId( } @Override - public List findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( + public List findPostsByWriterWithFilteringAndPaging( final Member writer, final PostClosingType postClosingType, final PostSortType postSortType, - final Long categoryId, final Pageable pageable ) { return jpaQueryFactory - .selectFrom(post) + .select(post) + .from(post) .join(post.writer).fetchJoin() .where( - categoryIdEq(categoryId), - deadlineEq(postClosingType), post.writer.eq(writer), + deadlineEq(postClosingType), post.isHidden.eq(false) ) .orderBy(orderBy(postSortType)) @@ -75,44 +78,43 @@ private BooleanExpression categoryIdEq(final Long categoryId) { private BooleanExpression deadlineEq(final PostClosingType postClosingType) { final LocalDateTime now = LocalDateTime.now(); - switch (postClosingType) { - case PROGRESS: - return post.deadline.after(now); - case CLOSED: - return post.deadline.before(now); - case ALL: - default: - return null; - } + return switch (postClosingType) { + case PROGRESS -> post.postDeadline.deadline.after(now); + case CLOSED -> post.postDeadline.deadline.before(now); + default -> null; + }; } private OrderSpecifier orderBy(final PostSortType postSortType) { - switch (postSortType) { - case LATEST: - return post.createdAt.desc(); - case HOT: - return post.totalVoteCount.desc(); - default: - return OrderByNull.DEFAULT; - } + return switch (postSortType) { + case LATEST -> post.createdAt.desc(); + case HOT -> new OrderSpecifier<>( + Order.DESC, + JPAExpressions.select(vote.id.count()) + .from(vote) + .where(vote.postOption.id.in( + JPAExpressions.select(postOption.id) + .from(postOption) + .where(postOption.post.id.eq(post.id)) + )) + ); + default -> OrderByNull.DEFAULT; + }; } @Override - public List findAllWithKeyword( + public List findSearchPostsWithFilteringAndPaging( final String keyword, final PostClosingType postClosingType, final PostSortType postSortType, - final Long categoryId, final Pageable pageable ) { return jpaQueryFactory - .selectFrom(post) - .distinct() + .select(post) + .from(post) .join(post.writer).fetchJoin() - .leftJoin(post.postCategories.postCategories, postCategory) .where( containsKeywordInTitleOrContent(keyword), - categoryIdEq(categoryId), deadlineEq(postClosingType), post.isHidden.eq(false) ) @@ -127,4 +129,28 @@ private BooleanExpression containsKeywordInTitleOrContent(final String keyword) .or(post.postBody.content.contains(keyword)); } + @Override + public List findPostsByVotedWithFilteringAndPaging( + final Member voter, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Pageable pageable + ) { + return jpaQueryFactory + .selectDistinct(post) + .from(post) + .join(post.writer).fetchJoin() + .leftJoin(post.postOptions, postOption) + .leftJoin(postOption.votes, vote) + .where( + vote.member.eq(voter), + deadlineEq(postClosingType), + post.isHidden.eq(false) + ) + .orderBy(orderBy(postSortType)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java index e4507c0ec..fc63abf05 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostOptionRepository.java @@ -2,7 +2,14 @@ import com.votogether.domain.post.entity.PostOption; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostOptionRepository extends JpaRepository { + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from PostOption p where p.post.id = :postId") + void deleteAllWithPostIdInBatch(@Param("postId") final Long postId); + } diff --git a/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java b/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java index 15fb326d6..09ef525fa 100644 --- a/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java +++ b/backend/src/main/java/com/votogether/domain/post/repository/PostRepository.java @@ -2,24 +2,13 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; -import java.time.LocalDateTime; import java.util.List; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository, PostCustomRepository { - Slice findByDeadlineBefore(final LocalDateTime currentTime, final Pageable pageable); - - Slice findByDeadlineAfter(final LocalDateTime currentTime, final Pageable pageable); - - int countByWriter(final Member member); - - List findAllByWriter(final Member member); - @Query("SELECT COUNT(p)" + "FROM Member m " + "LEFT JOIN Post p ON m.id = p.writer.id AND p.writer IN :members " + @@ -27,15 +16,8 @@ public interface PostRepository extends JpaRepository, PostCustomRep "GROUP BY m.id") List findCountsByMembers(@Param("members") final List members); - @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member " - + "AND v.postOption.post.deadline < CURRENT_TIMESTAMP") - Slice findClosedPostsVotedByMember(@Param("member") final Member member, final Pageable pageable); - - @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member " - + "AND v.postOption.post.deadline > CURRENT_TIMESTAMP") - Slice findOpenPostsVotedByMember(@Param("member") final Member member, final Pageable pageable); + List findAllByWriter(final Member member); - @Query("SELECT v.postOption.post FROM Vote v WHERE v.member = :member ") - Slice findPostsVotedByMember(@Param("member") final Member member, final Pageable pageable); + int countByWriter(final Member member); } diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostCommandService.java b/backend/src/main/java/com/votogether/domain/post/service/PostCommandService.java new file mode 100644 index 000000000..8afd4b5fe --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/service/PostCommandService.java @@ -0,0 +1,368 @@ +package com.votogether.domain.post.service; + +import com.votogether.domain.category.repository.CategoryRepository; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.request.post.PostCreateRequest; +import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest; +import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest; +import com.votogether.domain.post.dto.request.post.PostUpdateRequest; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostContentImage; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.exception.PostOptionExceptionType; +import com.votogether.domain.post.repository.CommentRepository; +import com.votogether.domain.post.repository.PostCategoryRepository; +import com.votogether.domain.post.repository.PostContentImageRepository; +import com.votogether.domain.post.repository.PostOptionRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.domain.vote.repository.VoteRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.infra.image.ImageUploader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@Transactional +@Service +public class PostCommandService { + + private final ImageUploader imageUploader; + private final PostRepository postRepository; + private final CategoryRepository categoryRepository; + private final PostCategoryRepository postCategoryRepository; + private final PostContentImageRepository postContentImageRepository; + private final PostOptionRepository postOptionRepository; + private final VoteRepository voteRepository; + private final CommentRepository commentRepository; + private final ReportRepository reportRepository; + + public Long createPost(final PostCreateRequest postCreate, final Member loginMember) { + final Post post = Post.builder() + .writer(loginMember) + .title(postCreate.getTitle()) + .content(postCreate.getContent()) + .deadline(postCreate.getDeadline()) + .build(); + + addCategories(post, postCreate.getCategoryIds()); + addImage(post, postCreate.getImageFile()); + addPostOptions(post, postCreate.getPostOptions()); + + return postRepository.save(post).getId(); + } + + private void addCategories(final Post post, final List categoryIds) { + categoryRepository.findAllById(categoryIds).forEach(post::addCategory); + } + + private void addImage(final Post post, final MultipartFile multipartFile) { + final String imageUrl = imageUploader.upload(multipartFile); + if (Objects.nonNull(imageUrl)) { + post.addContentImage(imageUrl); + } + } + + private void addPostOptions(final Post post, final List postOptionsCreates) { + IntStream.range(0, postOptionsCreates.size()) + .mapToObj(index -> createPostOptionFromRequest(index + 1, postOptionsCreates.get(index))) + .forEach(post::addPostOption); + } + + private PostOption createPostOptionFromRequest(int sequence, final PostOptionCreateRequest postOptionCreate) { + final String imageUrl = imageUploader.upload(postOptionCreate.getImageFile()); + return PostOption.builder() + .sequence(sequence) + .content(postOptionCreate.getContent()) + .imageUrl(imageUrl) + .build(); + } + + public void updatePost( + final Long postId, + final PostUpdateRequest postUpdate, + final Member loginMember + ) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + validatePostWriter(post, loginMember); + validatePostOptionUpdateCount(post, postUpdate); + + updatePostOptions(post, postUpdate.getPostOptions()); + updatePost(post, postUpdate); + } + + private void updatePostOptions(final Post post, final List postOptionUpdates) { + final List postOptions = new ArrayList<>(post.getPostOptions()); + final Set postOptionIds = postOptions.stream() + .map(PostOption::getId) + .collect(Collectors.toSet()); + final List removePostOptions = extractPostOptionRemoves(postOptions, postOptionUpdates); + final Map updateOptionGroup = + extractPostOptionUpdates(postOptionIds, postOptionUpdates); + final List addPostOptions = + extractPostOptionAdds(postOptionIds, postOptionUpdates); + + removeOriginPostOptions(post, removePostOptions); + updateOriginPostOptions(post, updateOptionGroup); + addNewPostOptions(post, addPostOptions, post.getPostOptions().size()); + } + + private List extractPostOptionRemoves( + final List postOptions, + final List postOptionUpdates + ) { + final Set updateOptionIds = postOptionUpdates.stream() + .map(PostOptionUpdateRequest::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return postOptions.stream() + .filter(postOption -> !updateOptionIds.contains(postOption.getId())) + .toList(); + } + + private Map extractPostOptionUpdates( + final Set postOptionIds, + final List postOptionUpdates + ) { + return postOptionUpdates.stream() + .filter(postOptionUpdate -> + Objects.nonNull(postOptionUpdate.getId()) && postOptionIds.contains(postOptionUpdate.getId())) + .collect(Collectors.toMap( + PostOptionUpdateRequest::getId, + postOptionUpdate -> postOptionUpdate, + (exist, replace) -> { + throw new BadRequestException(PostOptionExceptionType.DUPLICATE_UPDATE); + }, + HashMap::new + )); + } + + private List extractPostOptionAdds( + final Set postOptionIds, + final List postOptionUpdates + ) { + validatePostOptionIds(postOptionIds, postOptionUpdates); + + return postOptionUpdates.stream() + .filter(postOptionUpdate -> postOptionUpdate.getId() == null) + .toList(); + } + + private static void validatePostOptionIds( + final Set postOptionIds, + final List postOptionUpdates + ) { + postOptionUpdates.stream() + .map(PostOptionUpdateRequest::getId) + .filter(Objects::nonNull) + .filter(id -> !postOptionIds.contains(id)) + .findFirst() + .ifPresent(id -> { + throw new BadRequestException(PostOptionExceptionType.UNRELATED_POST); + }); + } + + private void removeOriginPostOptions(final Post post, final List removePostOptions) { + for (final PostOption postOption : removePostOptions) { + imageUploader.delete(postOption.getImageUrl()); + post.removePostOption(postOption); + } + postOptionRepository.flush(); + } + + private void updateOriginPostOptions(final Post post, final Map updateOptionGroup) { + final List postOptions = post.getPostOptions(); + for (final PostOption postOption : postOptions) { + updatePostOption(postOption, updateOptionGroup.get(postOption.getId())); + } + for (int i = 0; i < postOptions.size(); i++) { + postOptions.get(i).setSequence(i + 1); + } + } + + private void updatePostOption(final PostOption postOption, final PostOptionUpdateRequest postOptionUpdate) { + final String imageUrl = updateImage( + postOption.getImageUrl(), + postOptionUpdate.getImageUrl(), + postOptionUpdate.getImageFile() + ); + postOption.update(postOptionUpdate.getContent(), imageUrl); + } + + private String updateImage( + final String originImageUrl, + final String clientUrl, + final MultipartFile multipartFile + ) { + final boolean hasOriginImage = Objects.nonNull(originImageUrl); + final boolean hasClientUrl = Objects.nonNull(clientUrl) && !clientUrl.isBlank(); + final boolean hasNewImage = multipartFile != null && !multipartFile.isEmpty(); + + if (hasOriginImage && (!hasClientUrl || hasNewImage)) { + imageUploader.delete(originImageUrl); + } + if (hasNewImage) { + return imageUploader.upload(multipartFile); + } + if (hasOriginImage && hasClientUrl) { + return originImageUrl; + } + return null; + } + + private void addNewPostOptions( + final Post post, + final List postOptionUpdates, + final int prevPostOptionSize + ) { + IntStream.range(0, postOptionUpdates.size()) + .mapToObj(index -> createNewPostOption(index, postOptionUpdates.get(index), prevPostOptionSize)) + .forEach(post::addPostOption); + } + + private PostOption createNewPostOption( + final int index, + final PostOptionUpdateRequest postOptionUpdate, + final int prevPostOptionSize + ) { + final String imageUrl = imageUploader.upload(postOptionUpdate.getImageFile()); + return PostOption.builder() + .sequence(index + 1 + prevPostOptionSize) + .content(postOptionUpdate.getContent()) + .imageUrl(imageUrl) + .build(); + } + + private void updatePost(final Post post, final PostUpdateRequest postUpdate) { + updatePostCategories(post, postUpdate.getCategoryIds()); + updatePostContentImage(post, postUpdate.getImageUrl(), postUpdate.getImageFile()); + post.update(postUpdate.getTitle(), postUpdate.getContent(), postUpdate.getDeadline()); + } + + private void updatePostCategories(final Post post, final List categoryIds) { + post.getPostCategories().clear(); + postCategoryRepository.flush(); + addCategories(post, categoryIds); + } + + private void updatePostContentImage( + final Post post, + final String imageUrl, + final MultipartFile multipartFile + ) { + final PostContentImage postContentImage = post.getFirstContentImage(); + final String currentImageUrl = postContentImage != null ? postContentImage.getImageUrl() : null; + final String updatedImageUrl = updateImage(currentImageUrl, imageUrl, multipartFile); + + if (postContentImage == null && updatedImageUrl != null) { + post.addContentImage(updatedImageUrl); + return; + } + updateExistingPostContentImage(post, postContentImage, updatedImageUrl); + } + + private void updateExistingPostContentImage( + final Post post, + final PostContentImage postContentImage, + final String updatedImageUrl + ) { + if (updatedImageUrl == null) { + post.removePostContentImage(postContentImage); + return; + } + postContentImage.updateImageUrl(updatedImageUrl); + } + + public void closePostEarly(final Long postId, final Member loginMember) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + validatePostWriter(post, loginMember); + post.closeEarly(); + } + + public void deletePost(final Long postId, final Member loginMember) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + validatePostWriter(post, loginMember); + validatePostDeletePossible(post); + + final List postOptions = post.getPostOptions(); + final List postContentImagePaths = post.getPostContentImages() + .stream() + .map(PostContentImage::getImageUrl) + .toList(); + + deleteVotes(postOptions); + deletePostOptions(postId, postOptions); + deleteComments(postId); + deletePost(postId, postContentImagePaths); + } + + private void deleteVotes(final List postOptions) { + final List postOptionIds = postOptions.stream() + .map(PostOption::getId) + .toList(); + voteRepository.deleteAllWithPostOptionIdsInBatch(postOptionIds); + } + + private void deletePostOptions(final Long postId, final List postOptions) { + postOptions.stream() + .map(PostOption::getImageUrl) + .forEach(imageUploader::delete); + postOptionRepository.deleteAllWithPostIdInBatch(postId); + } + + private void deleteComments(final Long postId) { + commentRepository.deleteAllWithPostIdInBatch(postId); + } + + private void deletePost(final Long postId, final List postContentImagePaths) { + postCategoryRepository.deleteAllWithPostIdInBatch(postId); + postContentImagePaths.forEach(imageUploader::delete); + postContentImageRepository.deleteAllWithPostIdInBatch(postId); + reportRepository.deleteAllWithReportTypeAndTargetIdInBatch(ReportType.POST, postId); + postRepository.deleteById(postId); + } + + private void validateHiddenPost(final Post post) { + if (post.isHidden()) { + throw new BadRequestException(PostExceptionType.IS_HIDDEN); + } + } + + private void validatePostWriter(final Post post, final Member member) { + if (!post.isWriter(member)) { + throw new BadRequestException(PostExceptionType.NOT_WRITER); + } + } + + private void validatePostOptionUpdateCount(final Post post, final PostUpdateRequest postUpdate) { + if (!post.isLimitOptionSize(postUpdate.getPostOptions().size())) { + throw new BadRequestException(PostOptionExceptionType.SIZE_EXCEED); + } + } + + private void validatePostDeletePossible(final Post post) { + if (!post.canDelete()) { + throw new BadRequestException(PostExceptionType.FAIL_DELETE_EXCEED); + } + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java index 183d205e3..c01ea6024 100644 --- a/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java +++ b/backend/src/main/java/com/votogether/domain/post/service/PostCommentService.java @@ -1,7 +1,7 @@ package com.votogether.domain.post.service; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; +import com.votogether.domain.post.dto.request.comment.CommentCreateRequest; import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest; import com.votogether.domain.post.dto.response.comment.CommentResponse; import com.votogether.domain.post.entity.Post; @@ -10,6 +10,7 @@ import com.votogether.domain.post.exception.PostExceptionType; import com.votogether.domain.post.repository.CommentRepository; import com.votogether.domain.post.repository.PostRepository; +import com.votogether.global.exception.BadRequestException; import com.votogether.global.exception.NotFoundException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -23,63 +24,92 @@ public class PostCommentService { private final PostRepository postRepository; private final CommentRepository commentRepository; + @Transactional(readOnly = true) + public List getComments(final Long postId) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + + return commentRepository.findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(post) + .stream() + .map(CommentResponse::from) + .toList(); + } + @Transactional public void createComment( - final Member member, final Long postId, - final CommentRegisterRequest commentRegisterRequest + final CommentCreateRequest commentCreateRequest, + final Member loginMember ) { final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); final Comment comment = Comment.builder() - .member(member) - .content(commentRegisterRequest.content()) + .writer(loginMember) + .content(commentCreateRequest.content()) .build(); - post.addComment(comment); } - @Transactional(readOnly = true) - public List getComments(final Long postId) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - - return commentRepository.findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(post) - .stream() - .map(CommentResponse::from) - .toList(); - } - @Transactional public void updateComment( final Long postId, final Long commentId, final CommentUpdateRequest commentUpdateRequest, - final Member member + final Member loginMember ) { final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); final Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(CommentExceptionType.NOT_FOUND)); - comment.validateBelong(post); - comment.validateWriter(member); + validateHiddenPost(post); + validateHiddenComment(comment); + validateBelongsToPost(comment, post); + validateWriter(comment, loginMember); comment.updateContent(commentUpdateRequest.content()); } @Transactional - public void deleteComment(final Long postId, final Long commentId, final Member member) { + public void deleteComment(final Long postId, final Long commentId, final Member loginMember) { final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); final Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND)); + .orElseThrow(() -> new NotFoundException(CommentExceptionType.NOT_FOUND)); - comment.validateBelong(post); - comment.validateWriter(member); + validateHiddenPost(post); + validateHiddenComment(comment); + validateBelongsToPost(comment, post); + validateWriter(comment, loginMember); commentRepository.delete(comment); } + private void validateHiddenPost(final Post post) { + if (post.isHidden()) { + throw new BadRequestException(PostExceptionType.IS_HIDDEN); + } + } + + private void validateHiddenComment(final Comment comment) { + if (comment.isHidden()) { + throw new BadRequestException(CommentExceptionType.IS_HIDDEN); + } + } + + private void validateBelongsToPost(final Comment comment, final Post post) { + if (!comment.belongsTo(post)) { + throw new BadRequestException(CommentExceptionType.NOT_BELONG_POST); + } + } + + private void validateWriter(final Comment comment, final Member member) { + if (!comment.isWriter(member)) { + throw new BadRequestException(CommentExceptionType.NOT_WRITER); + } + } + } diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostGuestService.java b/backend/src/main/java/com/votogether/domain/post/service/PostGuestService.java new file mode 100644 index 000000000..86edefe93 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/service/PostGuestService.java @@ -0,0 +1,132 @@ +package com.votogether.domain.post.service; + +import com.votogether.domain.post.dto.response.post.PostRankingResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.post.PostSummaryResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.repository.PostCategoryRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class PostGuestService { + + private static final int BASIC_PAGE_SIZE = 10; + + private final PostRepository postRepository; + private final PostCategoryRepository postCategoryRepository; + + @Transactional(readOnly = true) + public List getPosts( + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Long categoryId + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = + postRepository.findPostsWithFilteringAndPaging(postClosingType, postSortType, categoryId, pageable); + return convertToResponses(posts); + } + + @Transactional(readOnly = true) + public PostResponse getPost(final Long postId) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + + return PostResponse.ofGuest( + post, + postCategoryRepository.findAllByPost(post), + post.getFirstContentImage(), + post.getPostOptions() + ); + } + + @Transactional(readOnly = true) + public List searchPosts( + final String keyword, + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = + postRepository.findSearchPostsWithFilteringAndPaging(keyword, postClosingType, postSortType, pageable); + return convertToResponses(posts); + } + + private void validateHiddenPost(final Post post) { + if (post.isHidden()) { + throw new BadRequestException(PostExceptionType.IS_HIDDEN); + } + } + + private List convertToResponses(final List posts) { + return posts.stream() + .map(post -> + PostResponse.ofGuest( + post, + postCategoryRepository.findAllByPost(post), + post.getFirstContentImage(), + post.getPostOptions() + ) + ) + .toList(); + } + + @Transactional(readOnly = true) + public List getRanking() { + final Pageable pageable = PageRequest.of(0, BASIC_PAGE_SIZE); + final List posts = postRepository.findPostsWithFilteringAndPaging( + PostClosingType.ALL, + PostSortType.HOT, + null, + pageable + ); + + final Map rankings = calculateRanking(posts); + + return rankings.entrySet().stream() + .map(entry -> + new PostRankingResponse( + entry.getValue(), + PostSummaryResponse.from(entry.getKey()) + ) + ) + .toList(); + } + + private Map calculateRanking(final List posts) { + final Map rankings = new LinkedHashMap<>(); + + int currentRanking = 1; + int previousRanking = -1; + long previousVoteCount = -1; + for (Post post : posts) { + final long currentVoteCount = post.getTotalVoteCount(); + final int ranking = (currentVoteCount == previousVoteCount) ? previousRanking : currentRanking; + rankings.put(post, ranking); + + previousRanking = ranking; + previousVoteCount = currentVoteCount; + currentRanking++; + } + + return rankings; + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostQueryService.java b/backend/src/main/java/com/votogether/domain/post/service/PostQueryService.java new file mode 100644 index 000000000..309594ecc --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/post/service/PostQueryService.java @@ -0,0 +1,179 @@ +package com.votogether.domain.post.service; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.exception.PostOptionExceptionType; +import com.votogether.domain.post.repository.PostCategoryRepository; +import com.votogether.domain.post.repository.PostOptionRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.domain.vote.repository.VoteRepository; +import com.votogether.domain.vote.repository.dto.VoteCountByAgeGroupAndGenderDto; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class PostQueryService { + + private static final int BASIC_PAGE_SIZE = 10; + + private final PostRepository postRepository; + private final PostCategoryRepository postCategoryRepository; + private final PostOptionRepository postOptionRepository; + private final VoteRepository voteRepository; + + public List getPosts( + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Long categoryId, + final Member loginMember + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = + postRepository.findPostsWithFilteringAndPaging(postClosingType, postSortType, categoryId, pageable); + return convertToResponses(posts, loginMember); + } + + public PostResponse getPost(final Long postId, final Member loginMember) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + + return PostResponse.ofUser( + loginMember, + post, + postCategoryRepository.findAllByPost(post), + post.getFirstContentImage(), + post.getPostOptions(), + voteRepository.findByMemberAndPostOptionPost(loginMember, post) + ); + } + + public List searchPosts( + final String keyword, + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Member loginMember + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = + postRepository.findSearchPostsWithFilteringAndPaging(keyword, postClosingType, postSortType, pageable); + return convertToResponses(posts, loginMember); + } + + public List getPostsWrittenByMe( + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Member loginMember + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = postRepository.findPostsByWriterWithFilteringAndPaging( + loginMember, + postClosingType, + postSortType, + pageable + ); + return convertToResponses(posts, loginMember); + } + + public List getPostsVotedByMe( + final int page, + final PostClosingType postClosingType, + final PostSortType postSortType, + final Member loginMember + ) { + final Pageable pageable = PageRequest.of(page, BASIC_PAGE_SIZE); + final List posts = postRepository.findPostsByVotedWithFilteringAndPaging( + loginMember, + postClosingType, + postSortType, + pageable + ); + return convertToResponses(posts, loginMember); + } + + public VoteOptionStatisticsResponse getVoteStatistics(final Long postId, final Member loginMember) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validateHiddenPost(post); + validatePostWriter(post, loginMember); + + final List result = + voteRepository.findPostVoteCountByAgeGroupAndGender(post.getId()) + .stream() + .map(VoteCountByAgeGroupAndGenderDto::from) + .toList(); + return VoteOptionStatisticsResponse.from(result); + } + + public VoteOptionStatisticsResponse getVoteOptionStatistics( + final Long postId, + final Long optionId, + final Member loginMember + ) { + final Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + final PostOption postOption = postOptionRepository.findById(optionId) + .orElseThrow(() -> new NotFoundException(PostOptionExceptionType.NOT_FOUND)); + validateHiddenPost(post); + validatePostWriter(post, loginMember); + validatePostOptionBelongPost(post, postOption); + + final List result = + voteRepository.findPostOptionVoteCountByAgeGroupAndGender(postOption.getId()) + .stream() + .map(VoteCountByAgeGroupAndGenderDto::from) + .toList(); + return VoteOptionStatisticsResponse.from(result); + } + + private void validateHiddenPost(final Post post) { + if (post.isHidden()) { + throw new BadRequestException(PostExceptionType.IS_HIDDEN); + } + } + + private void validatePostWriter(final Post post, final Member member) { + if (!post.isWriter(member)) { + throw new BadRequestException(PostExceptionType.NOT_WRITER); + } + } + + private void validatePostOptionBelongPost(final Post post, final PostOption postOption) { + if (!postOption.belongsTo(post)) { + throw new BadRequestException(PostOptionExceptionType.UNRELATED_POST); + } + } + + private List convertToResponses(final List posts, final Member member) { + return posts.stream() + .map(post -> + PostResponse.ofUser( + member, + post, + postCategoryRepository.findAllByPost(post), + post.getFirstContentImage(), + post.getPostOptions(), + voteRepository.findByMemberAndPostOptionPost(member, post) + ) + ) + .toList(); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/post/service/PostService.java b/backend/src/main/java/com/votogether/domain/post/service/PostService.java deleted file mode 100644 index a658d0383..000000000 --- a/backend/src/main/java/com/votogether/domain/post/service/PostService.java +++ /dev/null @@ -1,410 +0,0 @@ -package com.votogether.domain.post.service; - -import com.votogether.domain.category.entity.Category; -import com.votogether.domain.category.repository.CategoryRepository; -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.entity.vo.AgeRange; -import com.votogether.domain.member.entity.vo.Gender; -import com.votogether.domain.member.exception.MemberExceptionType; -import com.votogether.domain.post.dto.request.post.PostCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest; -import com.votogether.domain.post.dto.request.post.PostUpdateRequest; -import com.votogether.domain.post.dto.response.post.PostDetailResponse; -import com.votogether.domain.post.dto.response.post.PostRankingResponse; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.dto.response.post.PostSummaryResponse; -import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.domain.post.repository.PostOptionRepository; -import com.votogether.domain.post.repository.PostRepository; -import com.votogether.domain.vote.repository.VoteRepository; -import com.votogether.domain.vote.repository.dto.VoteStatus; -import com.votogether.global.exception.BadRequestException; -import com.votogether.global.exception.NotFoundException; -import com.votogether.global.util.ImageUploader; -import java.time.LocalDate; -import java.util.EnumMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.stream.Collectors; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@Service -public class PostService { - - private static final int BASIC_PAGING_SIZE = 10; - private static final int MAXIMUM_DEADLINE = 3; - - private final Map>> postsVotedByMemberMapper; - private final PostRepository postRepository; - private final PostOptionRepository postOptionRepository; - private final CategoryRepository categoryRepository; - private final VoteRepository voteRepository; - - public PostService( - final PostRepository postRepository, - final PostOptionRepository postOptionRepository, - final CategoryRepository categoryRepository, - final VoteRepository voteRepository - ) { - this.postRepository = postRepository; - this.postOptionRepository = postOptionRepository; - this.categoryRepository = categoryRepository; - this.voteRepository = voteRepository; - this.postsVotedByMemberMapper = new EnumMap<>(PostClosingType.class); - initPostsVotedByMemberMapper(); - } - - private void initPostsVotedByMemberMapper() { - postsVotedByMemberMapper.put(PostClosingType.ALL, postRepository::findPostsVotedByMember); - postsVotedByMemberMapper.put(PostClosingType.PROGRESS, postRepository::findOpenPostsVotedByMember); - postsVotedByMemberMapper.put(PostClosingType.CLOSED, postRepository::findClosedPostsVotedByMember); - } - - @Transactional - public Long save( - final PostCreateRequest postCreateRequest, - final Member loginMember, - final List contentImages, - final List optionImages - ) { - final List categories = categoryRepository.findAllById(postCreateRequest.categoryIds()); - final Post post = toPostEntity(postCreateRequest, loginMember, contentImages, optionImages, categories); - post.validateDeadlineNotExceedByMaximumDeadline(MAXIMUM_DEADLINE); - - return postRepository.save(post).getId(); - } - - private Post toPostEntity( - final PostCreateRequest postCreateRequest, - final Member loginMember, - final List contentImages, - final List optionImages, - final List categories - ) { - final Post post = toPost(postCreateRequest, loginMember); - - post.mapPostOptionsByElements( - transformElements(postCreateRequest.postOptions(), PostOptionCreateRequest::content), - transformElements(optionImages, ImageUploader::upload) - ); - post.mapCategories(categories); - addContentImageIfPresent(post, contentImages); - - return post; - } - - private Post toPost( - final PostCreateRequest postCreateRequest, - final Member loginMember - ) { - return Post.builder() - .writer(loginMember) - .postBody(toPostBody(postCreateRequest.title(), postCreateRequest.content())) - .deadline(postCreateRequest.deadline()) - .build(); - } - - private PostBody toPostBody(final String title, final String content) { - return PostBody.builder() - .title(title) - .content(content) - .build(); - } - - private void addContentImageIfPresent(final Post post, final List contentImages) { - if (isContentImagesPresent(contentImages)) { - post.addContentImage(ImageUploader.upload(contentImages.get(0))); - } - } - - private boolean isContentImagesPresent(final List contentImages) { - return Objects.nonNull(contentImages) && !contentImages.isEmpty(); - } - - @Transactional(readOnly = true) - public List getAllPostBySortTypeAndClosingTypeAndCategoryId( - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Long categoryId, - final Member loginMember - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE, postSortType.getPostBaseSort()); - final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - postClosingType, - postSortType, - categoryId, - pageable - ); - return posts.stream() - .map(post -> PostResponse.of(post, loginMember)) - .toList(); - } - - @Transactional(readOnly = true) - public List getPostsGuest( - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Long categoryId - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE); - final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - postClosingType, - postSortType, - categoryId, - pageable - ); - - return transformElements(posts, PostResponse::forGuest); - } - - @Transactional(readOnly = true) - public PostDetailResponse getPostById(final Long postId, final Member loginMember) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - - return PostDetailResponse.of(post, loginMember); - } - - @Transactional(readOnly = true) - public VoteOptionStatisticsResponse getVoteStatistics(final long postId, final Member member) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - - post.validateWriter(member); - - final List voteStatuses = - voteRepository.findVoteCountByPostIdGroupByAgeRangeAndGender(post.getId()); - return VoteOptionStatisticsResponse.from(groupVoteStatus(voteStatuses)); - } - - @Transactional(readOnly = true) - public VoteOptionStatisticsResponse getVoteOptionStatistics( - final long postId, - final long optionId, - final Member member - ) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - final PostOption postOption = postOptionRepository.findById(optionId) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_OPTION_NOT_FOUND)); - - if (!postOption.isBelongsTo(post)) { - throw new BadRequestException(PostExceptionType.UNRELATED_POST_OPTION); - } - post.validateWriter(member); - - final List voteStatuses = - voteRepository.findVoteCountByPostOptionIdGroupByAgeRangeAndGender(postOption.getId()); - return VoteOptionStatisticsResponse.from(groupVoteStatus(voteStatuses)); - } - - private Map> groupVoteStatus(final List voteStatuses) { - return voteStatuses.stream() - .collect(Collectors.groupingBy( - status -> groupAgeRange(status.birthYear()), - LinkedHashMap::new, - Collectors.groupingBy( - VoteStatus::gender, - LinkedHashMap::new, - Collectors.summingLong(VoteStatus::count) - ) - )); - } - - private String groupAgeRange(final Integer birthYear) { - final int age = LocalDate.now().getYear() - birthYear + 1; - if (age <= 0) { - throw new BadRequestException(MemberExceptionType.INVALID_AGE); - } - return AgeRange.from(age).getName(); - } - - @Transactional(readOnly = true) - public List getPostsVotedByMember( - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Member member - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE, postSortType.getVoteBaseSort()); - - Slice posts = postsVotedByMemberMapper.get(postClosingType).apply(member, pageable); - - return posts.stream() - .map(post -> PostResponse.of(post, member)) - .toList(); - } - - @Transactional - public void closePostEarlyById(final Long id, final Member loginMember) { - final Post post = postRepository.findById(id) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - - post.validateWriter(loginMember); - post.validateDeadLine(); - post.closeEarly(); - } - - @Transactional(readOnly = true) - public List getPostsByWriter( - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Long categoryId, - final Member member - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE); - final List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - member, - postClosingType, - postSortType, - categoryId, - pageable - ); - - return posts.stream() - .map(post -> PostResponse.of(post, member)) - .toList(); - } - - public List searchPostsWithKeyword( - final String keyword, - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Long categoryId, - final Member member - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE); - final List posts = - postRepository.findAllWithKeyword(keyword, postClosingType, postSortType, categoryId, pageable); - - return posts.stream() - .map(post -> PostResponse.of(post, member)) - .toList(); - } - - public List searchPostsWithKeywordForGuest( - final String keyword, - final int page, - final PostClosingType postClosingType, - final PostSortType postSortType, - final Long categoryId - ) { - final Pageable pageable = PageRequest.of(page, BASIC_PAGING_SIZE); - final List posts = - postRepository.findAllWithKeyword(keyword, postClosingType, postSortType, categoryId, pageable); - - return posts.stream() - .map(PostResponse::forGuest) - .toList(); - } - - @Transactional - public void delete(final Long postId) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(PostExceptionType.POST_NOT_FOUND)); - post.validatePossibleToDelete(); - - postRepository.deleteById(postId); - } - - @Transactional - public void update( - final long postId, - final PostUpdateRequest request, - final Member member, - final List contentImages, - final List optionImages - ) { - final Post post = postRepository.findById(postId) - .orElseThrow(() -> new BadRequestException(PostExceptionType.POST_NOT_FOUND)); - - post.validateExistVote(); - post.validateWriter(member); - post.validateDeadLine(); - post.validateDeadLineToModify(request.deadline()); - - postOptionsInit(post); - post.update( - toPostBody(request.title(), request.content()), - request.imageUrl(), - transformElements(contentImages, ImageUploader::upload), - categoryRepository.findAllById(request.categoryIds()), - transformElements(request.postOptions(), PostOptionUpdateRequest::content), - transformElements(request.postOptions(), PostOptionUpdateRequest::imageUrl), - transformElements(optionImages, ImageUploader::upload), - request.deadline() - ); - } - - private void postOptionsInit(final Post post) { - post.postOptionsClear(); - postRepository.flush(); - } - - private List transformElements(final List elements, final Function process) { - return elements.stream() - .map(process) - .toList(); - } - - @Transactional(readOnly = true) - public List getRanking() { - final List posts = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.ALL, - PostSortType.HOT, - null, - PageRequest.of(0, BASIC_PAGING_SIZE) - ); - - final Map rankings = calculateRanking(posts); - - return rankings.entrySet().stream() - .map(entry -> - new PostRankingResponse( - entry.getValue(), - PostSummaryResponse.from(entry.getKey()) - ) - ) - .toList(); - } - - private Map calculateRanking(final List posts) { - final Map rankings = new LinkedHashMap<>(); - - int currentRanking = 1; - int previousRanking = -1; - long previousVoteCount = -1; - for (Post post : posts) { - final long currentVoteCount = post.getTotalVoteCount(); - final int ranking = (currentVoteCount == previousVoteCount) ? previousRanking : currentRanking; - rankings.put(post, ranking); - - previousRanking = ranking; - previousVoteCount = currentVoteCount; - currentRanking++; - } - - return rankings; - } -} - diff --git a/backend/src/main/java/com/votogether/domain/report/controller/ReportController.java b/backend/src/main/java/com/votogether/domain/report/controller/ReportCommandCommandController.java similarity index 73% rename from backend/src/main/java/com/votogether/domain/report/controller/ReportController.java rename to backend/src/main/java/com/votogether/domain/report/controller/ReportCommandCommandController.java index fc94dfd68..941ce0740 100644 --- a/backend/src/main/java/com/votogether/domain/report/controller/ReportController.java +++ b/backend/src/main/java/com/votogether/domain/report/controller/ReportCommandCommandController.java @@ -2,7 +2,7 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.report.dto.request.ReportRequest; -import com.votogether.domain.report.service.ReportService; +import com.votogether.domain.report.service.ReportCommandService; import com.votogether.global.jwt.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -13,13 +13,13 @@ @RequiredArgsConstructor @RestController -public class ReportController implements ReportControllerDocs { +public class ReportCommandCommandController implements ReportCommandControllerDocs { - private final ReportService reportService; + private final ReportCommandService reportCommandService; @PostMapping("/report") public ResponseEntity report(@Valid @RequestBody final ReportRequest request, @Auth final Member member) { - reportService.report(member, request); + reportCommandService.report(member, request); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java b/backend/src/main/java/com/votogether/domain/report/controller/ReportCommandControllerDocs.java similarity index 96% rename from backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java rename to backend/src/main/java/com/votogether/domain/report/controller/ReportCommandControllerDocs.java index a07c32024..aab4566b5 100644 --- a/backend/src/main/java/com/votogether/domain/report/controller/ReportControllerDocs.java +++ b/backend/src/main/java/com/votogether/domain/report/controller/ReportCommandControllerDocs.java @@ -12,7 +12,7 @@ import org.springframework.http.ResponseEntity; @Tag(name = "신고", description = "신고 API") -public interface ReportControllerDocs { +public interface ReportCommandControllerDocs { @Operation(summary = "신고", description = "게시글, 댓글, 닉네임을 신고한다.") @ApiResponses({ diff --git a/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java b/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java index 9f56a5d0b..85fc48e1a 100644 --- a/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java +++ b/backend/src/main/java/com/votogether/domain/report/repository/ReportRepository.java @@ -6,6 +6,9 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ReportRepository extends JpaRepository { @@ -21,4 +24,11 @@ Optional findByMemberAndReportTypeAndTargetId( List findAllByReportTypeAndTargetId(final ReportType reportType, final Long targetId); + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from Report r where r.reportType = :reportType and r.targetId = :targetId") + void deleteAllWithReportTypeAndTargetIdInBatch( + @Param("reportType") ReportType reportType, + @Param("targetId") final Long targetId + ); + } diff --git a/backend/src/main/java/com/votogether/domain/report/service/ReportCommandService.java b/backend/src/main/java/com/votogether/domain/report/service/ReportCommandService.java new file mode 100644 index 000000000..c89e0d658 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/report/service/ReportCommandService.java @@ -0,0 +1,37 @@ +package com.votogether.domain.report.service; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.domain.report.service.strategy.ReportCommentStrategy; +import com.votogether.domain.report.service.strategy.ReportNicknameStrategy; +import com.votogether.domain.report.service.strategy.ReportPostStrategy; +import com.votogether.domain.report.service.strategy.ReportStrategy; +import java.util.EnumMap; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +public class ReportCommandService { + + private final Map reportActions; + + public ReportCommandService( + final ReportPostStrategy reportPostStrategy, + final ReportCommentStrategy reportCommentStrategy, + final ReportNicknameStrategy reportNicknameStrategy + ) { + this.reportActions = new EnumMap<>(ReportType.class); + this.reportActions.put(ReportType.POST, reportPostStrategy); + this.reportActions.put(ReportType.COMMENT, reportCommentStrategy); + this.reportActions.put(ReportType.NICKNAME, reportNicknameStrategy); + } + + public void report(final Member reporter, final ReportRequest request) { + final ReportStrategy reportStrategy = reportActions.get(request.type()); + reportStrategy.report(reporter, request); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/report/service/ReportService.java b/backend/src/main/java/com/votogether/domain/report/service/ReportService.java deleted file mode 100644 index 5357b6c02..000000000 --- a/backend/src/main/java/com/votogether/domain/report/service/ReportService.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.votogether.domain.report.service; - -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.exception.MemberExceptionType; -import com.votogether.domain.member.repository.MemberRepository; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.comment.Comment; -import com.votogether.domain.post.exception.CommentExceptionType; -import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.domain.post.repository.CommentRepository; -import com.votogether.domain.post.repository.PostRepository; -import com.votogether.domain.report.dto.request.ReportRequest; -import com.votogether.domain.report.entity.Report; -import com.votogether.domain.report.entity.vo.ReportType; -import com.votogether.domain.report.exception.ReportExceptionType; -import com.votogether.domain.report.repository.ReportRepository; -import com.votogether.global.exception.BadRequestException; -import com.votogether.global.exception.NotFoundException; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -public class ReportService { - - private final ReportRepository reportRepository; - private final PostRepository postRepository; - private final CommentRepository commentRepository; - private final MemberRepository memberRepository; - - @Transactional - public void report(final Member reporter, final ReportRequest request) { - if (request.type() == ReportType.POST) { - reportPost(reporter, request); - } - if (request.type() == ReportType.COMMENT) { - reportComment(reporter, request); - } - if (request.type() == ReportType.NICKNAME) { - reportNickname(reporter, request); - } - } - - private void reportPost( - final Member reporter, - final ReportRequest request - ) { - final Post reportedPost = postRepository.findById(request.id()) - .orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND)); - validatePost(reporter, reportedPost, request); - - saveReport(reporter, request); - blindPost(request, reportedPost); - } - - private void validatePost( - final Member reporter, - final Post reportedPost, - final ReportRequest request - ) { - reportedPost.validateMine(reporter); - reportedPost.validateHidden(); - validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_POST_REPORT); - } - - private void validateDuplicatedReport( - final Member reporter, - final ReportRequest request, - final ReportExceptionType exceptionType - ) { - reportRepository.findByMemberAndReportTypeAndTargetId(reporter, request.type(), request.id()) - .ifPresent(report -> { - throw new BadRequestException(exceptionType); - }); - } - - private void saveReport(final Member reporter, final ReportRequest request) { - final Report report = Report.builder() - .member(reporter) - .reportType(request.type()) - .targetId(request.id()) - .reason(request.reason()) - .build(); - reportRepository.save(report); - } - - private void blindPost( - final ReportRequest request, - final Post reportedPost - ) { - final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id()); - if (reportCount >= 5) { - reportedPost.blind(); - } - } - - private void reportComment( - final Member reporter, - final ReportRequest request - ) { - final Comment reportedComment = commentRepository.findById(request.id()) - .orElseThrow(() -> new NotFoundException(CommentExceptionType.COMMENT_NOT_FOUND)); - validateComment(reporter, request, reportedComment); - - saveReport(reporter, request); - blindComment(request, reportedComment); - } - - private void validateComment( - final Member reporter, - final ReportRequest request, - final Comment reportedComment - ) { - reportedComment.validateMine(reporter); - reportedComment.validateHidden(); - validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_COMMENT_REPORT); - } - - private void blindComment( - final ReportRequest request, - final Comment reportedComment - ) { - final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id()); - if (reportCount >= 5) { - reportedComment.blind(); - } - } - - private void reportNickname( - final Member reporter, - final ReportRequest request - ) { - final Member reportedMember = memberRepository.findById(request.id()) - .orElseThrow(() -> new NotFoundException(MemberExceptionType.NONEXISTENT_MEMBER)); - validateNickname(reporter, request); - - saveReport(reporter, request); - changeNicknameByReport(reportedMember, request.type()); - } - - private void validateNickname( - final Member reporter, - final ReportRequest request - ) { - validateMyNickname(reporter, request); - validateDuplicatedReport(reporter, request, ReportExceptionType.DUPLICATE_NICKNAME_REPORT); - } - - private void validateMyNickname(final Member reporter, final ReportRequest request) { - if (Objects.equals(reporter.getId(), request.id())) { - throw new BadRequestException(ReportExceptionType.REPORT_MY_NICKNAME); - } - } - - private void changeNicknameByReport(final Member reportedMember, final ReportType reportType) { - final int reportCount = reportRepository.countByReportTypeAndTargetId(reportType, reportedMember.getId()); - if (reportCount >= 3) { - reportedMember.changeNicknameByReport(); - } - } - -} diff --git a/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportCommentStrategy.java b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportCommentStrategy.java new file mode 100644 index 000000000..302f84626 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportCommentStrategy.java @@ -0,0 +1,68 @@ +package com.votogether.domain.report.service.strategy; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.exception.CommentExceptionType; +import com.votogether.domain.post.repository.CommentRepository; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.exception.ReportExceptionType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ReportCommentStrategy implements ReportStrategy { + + private static final int NUMBER_OF_COMMENT_BLIND_BASED_REPORTS = 5; + + private final CommentRepository commentRepository; + private final ReportRepository reportRepository; + + @Override + public void report(final Member reporter, final ReportRequest request) { + final Comment reportedComment = commentRepository.findById(request.id()) + .orElseThrow(() -> new NotFoundException(CommentExceptionType.NOT_FOUND)); + validateComment(reporter, request, reportedComment); + + saveReport(reporter, request, reportRepository); + blindComment(request, reportedComment); + } + + private void validateComment( + final Member reporter, + final ReportRequest request, + final Comment reportedComment + ) { + validateHiddenComment(reportedComment); + validateCommentMine(reportedComment, reporter); + validateDuplicatedReport( + reporter, + request, + ReportExceptionType.DUPLICATE_COMMENT_REPORT, + reportRepository + ); + } + + private void blindComment(final ReportRequest request, final Comment reportedComment) { + final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id()); + if (reportCount >= NUMBER_OF_COMMENT_BLIND_BASED_REPORTS) { + reportedComment.blind(); + } + } + + private void validateHiddenComment(final Comment comment) { + if (comment.isHidden()) { + throw new BadRequestException(CommentExceptionType.IS_HIDDEN); + } + } + + private void validateCommentMine(final Comment comment, final Member member) { + if (comment.isWriter(member)) { + throw new BadRequestException(CommentExceptionType.REPORT_MINE); + } + } + +} diff --git a/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategy.java b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategy.java new file mode 100644 index 000000000..c8714a42f --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategy.java @@ -0,0 +1,59 @@ +package com.votogether.domain.report.service.strategy; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.exception.MemberExceptionType; +import com.votogether.domain.member.repository.MemberRepository; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.domain.report.exception.ReportExceptionType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ReportNicknameStrategy implements ReportStrategy { + + private static final int NUMBER_OF_NICKNAME_CHANGE_REPORTS = 3; + + private final MemberRepository memberRepository; + private final ReportRepository reportRepository; + + @Override + public void report(final Member reporter, final ReportRequest request) { + final Member reportedMember = memberRepository.findById(request.id()) + .orElseThrow(() -> new NotFoundException(MemberExceptionType.NONEXISTENT_MEMBER)); + validateNickname(reporter, request); + + saveReport(reporter, request, reportRepository); + changeNicknameByReport(reportedMember, request); + } + + private void validateNickname(final Member reporter, final ReportRequest request) { + validateMyNickname(reporter, request); + validateDuplicatedReport( + reporter, + request, + ReportExceptionType.DUPLICATE_NICKNAME_REPORT, + reportRepository + ); + } + + private void validateMyNickname(final Member reporter, final ReportRequest request) { + if (Objects.equals(reporter.getId(), request.id())) { + throw new BadRequestException(ReportExceptionType.REPORT_MY_NICKNAME); + } + } + + private void changeNicknameByReport(final Member reportedMember, final ReportRequest request) { + final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), reportedMember.getId()); + if (reportCount >= NUMBER_OF_NICKNAME_CHANGE_REPORTS) { + reportedMember.changeNicknameByReport(); + reportRepository.deleteAllWithReportTypeAndTargetIdInBatch(ReportType.NICKNAME, request.id()); + } + } + +} diff --git a/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportPostStrategy.java b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportPostStrategy.java new file mode 100644 index 000000000..5ebc55d23 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportPostStrategy.java @@ -0,0 +1,68 @@ +package com.votogether.domain.report.service.strategy; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.exception.ReportExceptionType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ReportPostStrategy implements ReportStrategy { + + private static final int NUMBER_OF_POST_BLIND_BASED_REPORTS = 5; + + private final PostRepository postRepository; + private final ReportRepository reportRepository; + + @Override + public void report(final Member reporter, final ReportRequest request) { + final Post reportedPost = postRepository.findById(request.id()) + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); + validatePost(reporter, reportedPost, request); + + saveReport(reporter, request, reportRepository); + blindPost(request, reportedPost); + } + + private void validatePost( + final Member reporter, + final Post reportedPost, + final ReportRequest request + ) { + validateHiddenPost(reportedPost); + validatePostMine(reportedPost, reporter); + validateDuplicatedReport( + reporter, + request, + ReportExceptionType.DUPLICATE_POST_REPORT, + reportRepository + ); + } + + private void blindPost(final ReportRequest request, final Post reportedPost) { + final int reportCount = reportRepository.countByReportTypeAndTargetId(request.type(), request.id()); + if (reportCount >= NUMBER_OF_POST_BLIND_BASED_REPORTS) { + reportedPost.blind(); + } + } + + private void validateHiddenPost(final Post post) { + if (post.isHidden()) { + throw new BadRequestException(PostExceptionType.IS_HIDDEN); + } + } + + private void validatePostMine(final Post post, final Member member) { + if (post.isWriter(member)) { + throw new BadRequestException(PostExceptionType.REPORT_MINE); + } + } + +} diff --git a/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportStrategy.java b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportStrategy.java new file mode 100644 index 000000000..33b58a44e --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/report/service/strategy/ReportStrategy.java @@ -0,0 +1,41 @@ +package com.votogether.domain.report.service.strategy; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.Report; +import com.votogether.domain.report.exception.ReportExceptionType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.global.exception.BadRequestException; + +@FunctionalInterface +public interface ReportStrategy { + + void report(final Member reporter, final ReportRequest request); + + default void validateDuplicatedReport( + final Member reporter, + final ReportRequest request, + final ReportExceptionType reportExceptionType, + final ReportRepository reportRepository + ) { + reportRepository.findByMemberAndReportTypeAndTargetId(reporter, request.type(), request.id()) + .ifPresent(report -> { + throw new BadRequestException(reportExceptionType); + }); + } + + default void saveReport( + final Member reporter, + final ReportRequest request, + final ReportRepository reportRepository + ) { + final Report report = Report.builder() + .member(reporter) + .reportType(request.type()) + .targetId(request.id()) + .reason(request.reason()) + .build(); + reportRepository.save(report); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/vote/exception/VoteExceptionType.java b/backend/src/main/java/com/votogether/domain/vote/exception/VoteExceptionType.java new file mode 100644 index 000000000..6eda664d8 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/exception/VoteExceptionType.java @@ -0,0 +1,23 @@ +package com.votogether.domain.vote.exception; + +import com.votogether.global.exception.ExceptionType; +import lombok.Getter; + +@Getter +public enum VoteExceptionType implements ExceptionType { + + CANNOT_VOTE_MY_POST(700, "해당 게시글 작성자는 투표할 수 없습니다."), + NOT_FOUND(701, "참여한 투표가 존재하지 않습니다."), + ALREADY_VOTED(702, "이미 참여한 투표입니다."), + ; + + + private final int code; + private final String message; + + VoteExceptionType(final int code, final String message) { + this.code = code; + this.message = message; + } + +} diff --git a/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java b/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java index c508910dd..7a3697b71 100644 --- a/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java +++ b/backend/src/main/java/com/votogether/domain/vote/repository/VoteRepository.java @@ -1,45 +1,67 @@ package com.votogether.domain.vote.repository; import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.PostOption; import com.votogether.domain.vote.entity.Vote; -import com.votogether.domain.vote.repository.dto.VoteStatus; +import com.votogether.domain.vote.repository.dto.VoteCountByAgeGroupAndGenderInterface; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface VoteRepository extends JpaRepository { - @Query("SELECT new com.votogether.domain.vote.repository.dto.VoteStatus(m.birthYear, m.gender, COUNT(v))" + - " FROM Vote v" + - " JOIN v.member m" + - " JOIN v.postOption p" + - " WHERE p.post.id = :postId" + - " GROUP BY m.birthYear, m.gender" + - " ORDER BY m.birthYear DESC" + @Query(value = "select" + + " case" + + " when YEAR(CURRENT_DATE) - m.birth_year < 10 then 0" + + " when YEAR(CURRENT_DATE) - m.birth_year < 20 then 1" + + " when YEAR(CURRENT_DATE) - m.birth_year < 30 then 2" + + " when YEAR(CURRENT_DATE) - m.birth_year < 40 then 3" + + " when YEAR(CURRENT_DATE) - m.birth_year < 50 then 4" + + " when YEAR(CURRENT_DATE) - m.birth_year < 60 then 5" + + " else 6 end as ageGroup," + + " m.gender as gender," + + " count(m.id) as voteCount" + + " from vote as v" + + " left join member as m on v.member_id = m.id" + + " left join post_option as p on v.post_option_id = p.id" + + " where p.post_id = :post_id" + + " group by" + + " ageGroup, m.gender" + + " order by" + + " ageGroup, m.gender desc", + nativeQuery = true ) - List findVoteCountByPostIdGroupByAgeRangeAndGender(@Param("postId") final Long postId); - - @Query("SELECT new com.votogether.domain.vote.repository.dto.VoteStatus(m.birthYear, m.gender, COUNT(v))" + - " FROM Vote v" + - " JOIN v.member m" + - " WHERE v.postOption.id = :postOptionId" + - " GROUP BY m.birthYear, m.gender" + - " ORDER BY m.birthYear DESC" - ) - List findVoteCountByPostOptionIdGroupByAgeRangeAndGender( - @Param("postOptionId") final Long postOptionId + List findPostVoteCountByAgeGroupAndGender( + @Param("post_id") final Long postId ); - Optional findByMemberAndPostOption(final Member member, final PostOption postOption); - - List findAllByMemberAndPostOptionIn(final Member member, final List postOptions); - - int countByMember(final Member member); - - List findAllByMember(final Member member); + @Query(value = "select" + + " case" + + " when YEAR(CURRENT_DATE) - m.birth_year < 10 then 0" + + " when YEAR(CURRENT_DATE) - m.birth_year < 20 then 1" + + " when YEAR(CURRENT_DATE) - m.birth_year < 30 then 2" + + " when YEAR(CURRENT_DATE) - m.birth_year < 40 then 3" + + " when YEAR(CURRENT_DATE) - m.birth_year < 50 then 4" + + " when YEAR(CURRENT_DATE) - m.birth_year < 60 then 5" + + " else 6 end as ageGroup," + + " m.gender as gender," + + " count(m.id) as voteCount" + + " from vote as v" + + " left join member as m on v.member_id = m.id" + + " where v.post_option_id = :post_option_id" + + " group by" + + " ageGroup, m.gender" + + " order by" + + " ageGroup, m.gender desc", + nativeQuery = true + ) + List findPostOptionVoteCountByAgeGroupAndGender( + @Param("post_option_id") final Long postOptionId + ); @Query("SELECT COUNT(v)" + "FROM Member m " + @@ -48,4 +70,18 @@ List findVoteCountByPostOptionIdGroupByAgeRangeAndGender( "GROUP BY m.id") List findCountsByMembers(@Param("members") final List members); + Optional findByMemberAndPostOptionPost(final Member member, final Post post); + + Optional findByMemberAndPostOption(final Member member, final PostOption postOption); + + List findAllByMemberAndPostOptionIn(final Member member, final List postOptions); + + List findAllByMember(final Member member); + + int countByMember(final Member member); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from Vote v where v.postOption.id in :postOptionIds") + void deleteAllWithPostOptionIdsInBatch(@Param("postOptionIds") final List postOptionIds); + } diff --git a/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderDto.java b/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderDto.java new file mode 100644 index 000000000..3bd0ac7d4 --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderDto.java @@ -0,0 +1,22 @@ +package com.votogether.domain.vote.repository.dto; + +import com.querydsl.core.annotations.QueryProjection; +import com.votogether.domain.member.entity.vo.Gender; + +public record VoteCountByAgeGroupAndGenderDto(int ageGroup, Gender gender, long voteCount) { + + @QueryProjection + public VoteCountByAgeGroupAndGenderDto { + } + + public static VoteCountByAgeGroupAndGenderDto from( + final VoteCountByAgeGroupAndGenderInterface voteCountByAgeGroupAndGenderInterface + ) { + return new VoteCountByAgeGroupAndGenderDto( + voteCountByAgeGroupAndGenderInterface.getAgeGroup(), + voteCountByAgeGroupAndGenderInterface.getGender(), + voteCountByAgeGroupAndGenderInterface.getVoteCount() + ); + } + +} diff --git a/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderInterface.java b/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderInterface.java new file mode 100644 index 000000000..651d8afca --- /dev/null +++ b/backend/src/main/java/com/votogether/domain/vote/repository/dto/VoteCountByAgeGroupAndGenderInterface.java @@ -0,0 +1,13 @@ +package com.votogether.domain.vote.repository.dto; + +import com.votogether.domain.member.entity.vo.Gender; + +public interface VoteCountByAgeGroupAndGenderInterface { + + int getAgeGroup(); + + Gender getGender(); + + long getVoteCount(); + +} diff --git a/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java b/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java index 8273e0d7d..706c49c63 100644 --- a/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java +++ b/backend/src/main/java/com/votogether/domain/vote/service/VoteService.java @@ -3,11 +3,15 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.entity.PostOptions; +import com.votogether.domain.post.exception.PostExceptionType; +import com.votogether.domain.post.exception.PostOptionExceptionType; import com.votogether.domain.post.repository.PostOptionRepository; import com.votogether.domain.post.repository.PostRepository; import com.votogether.domain.vote.entity.Vote; +import com.votogether.domain.vote.exception.VoteExceptionType; import com.votogether.domain.vote.repository.VoteRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -28,12 +32,12 @@ public void vote( final Long postOptionId ) { final Post post = postRepository.findById(postId) - .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); validateAlreadyVoted(member, post); final PostOption postOption = postOptionRepository.findById(postOptionId) - .orElseThrow(() -> new IllegalArgumentException("해당 선택지가 존재 하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(PostOptionExceptionType.NOT_FOUND)); final Vote vote = post.makeVote(member, postOption); voteRepository.save(vote); @@ -41,11 +45,11 @@ public void vote( private void validateAlreadyVoted(final Member member, final Post post) { - final PostOptions postOptions = post.getPostOptions(); + final List postOptions = post.getPostOptions(); final List alreadyVoted = - voteRepository.findAllByMemberAndPostOptionIn(member, postOptions.getPostOptions()); + voteRepository.findAllByMemberAndPostOptionIn(member, postOptions); if (!alreadyVoted.isEmpty()) { - throw new IllegalStateException("해당 게시물에는 이미 투표하였습니다."); + throw new BadRequestException(VoteExceptionType.ALREADY_VOTED); } } @@ -56,16 +60,16 @@ public void changeVote( final Long newPostOptionId ) { final Post post = postRepository.findById(postId) - .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(PostExceptionType.NOT_FOUND)); final PostOption originPostOption = postOptionRepository.findById(originPostOptionId) - .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(PostOptionExceptionType.NOT_FOUND)); final Vote originVote = voteRepository.findByMemberAndPostOption(member, originPostOption) - .orElseThrow(() -> new IllegalArgumentException("선택지에 해당되는 투표가 존재하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(VoteExceptionType.NOT_FOUND)); final PostOption newPostOption = postOptionRepository.findById(newPostOptionId) - .orElseThrow(() -> new IllegalArgumentException("헤당 선택지가 존재하지 않습니다.")); + .orElseThrow(() -> new NotFoundException(PostOptionExceptionType.NOT_FOUND)); voteRepository.delete(originVote); final Vote vote = post.makeVote(member, newPostOption); diff --git a/backend/src/main/java/com/votogether/global/config/WebMvcConfig.java b/backend/src/main/java/com/votogether/global/config/WebMvcConfig.java index a24c5ff44..f8c6a29a9 100644 --- a/backend/src/main/java/com/votogether/global/config/WebMvcConfig.java +++ b/backend/src/main/java/com/votogether/global/config/WebMvcConfig.java @@ -12,7 +12,8 @@ public class WebMvcConfig implements WebMvcConfigurer { private static final String LOCALHOST_FRONTEND = "http://localhost:3000"; - private static final String DEV_SERVER = "http://dev.votogether.com"; + private static final String HTTPS_LOCALHOST_FRONTEND = "https://localhost:3000"; + private static final String DEV_SERVER = "https://dev.votogether.com"; private static final String PROD_SERVER = "https://votogether.com"; private final JwtAuthorizationArgumentResolver jwtAuthorizationArgumentResolver; @@ -30,7 +31,7 @@ public void addArgumentResolvers(final List resol public void addCorsMappings(final CorsRegistry registry) { registry.addMapping("/**") .allowedHeaders("*") - .allowedOrigins(LOCALHOST_FRONTEND, DEV_SERVER, PROD_SERVER) + .allowedOrigins(HTTPS_LOCALHOST_FRONTEND, LOCALHOST_FRONTEND, DEV_SERVER, PROD_SERVER) .allowedMethods("*") .allowCredentials(true) .exposedHeaders(HttpHeaders.LOCATION, HttpHeaders.SET_COOKIE); diff --git a/backend/src/main/java/com/votogether/global/exception/ErrorResponse.java b/backend/src/main/java/com/votogether/global/exception/ErrorResponse.java new file mode 100644 index 000000000..47f3c6293 --- /dev/null +++ b/backend/src/main/java/com/votogether/global/exception/ErrorResponse.java @@ -0,0 +1,22 @@ +package com.votogether.global.exception; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "요청 검증 에러 응답") +public record ErrorResponse( + @Schema(description = "검증 실패 필드", example = "id") + String fieldName, + + @Schema(description = "검증 실패 에러 메시지", example = "ID는 양수만 가능합니다.") + String message +) { + + @Override + public String toString() { + return "{" + + "fieldName='" + fieldName + '\'' + + ", message='" + message + '\'' + + '}'; + } + +} diff --git a/backend/src/main/java/com/votogether/global/exception/ExceptionResponse.java b/backend/src/main/java/com/votogether/global/exception/ExceptionResponse.java index 429791ebb..1ed7bba1d 100644 --- a/backend/src/main/java/com/votogether/global/exception/ExceptionResponse.java +++ b/backend/src/main/java/com/votogether/global/exception/ExceptionResponse.java @@ -6,6 +6,7 @@ public record ExceptionResponse( @Schema(description = "예외 코드", example = "-9999") int code, + @Schema(description = "예외 메시지", example = "알 수 없는 서버 예외가 발생하였습니다.") String message ) { diff --git a/backend/src/main/java/com/votogether/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/votogether/global/exception/GlobalExceptionHandler.java index cd2bbe06d..3d82d87d2 100644 --- a/backend/src/main/java/com/votogether/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/votogether/global/exception/GlobalExceptionHandler.java @@ -1,12 +1,13 @@ package com.votogether.global.exception; import com.votogether.domain.post.exception.PostExceptionType; +import jakarta.validation.ConstraintViolationException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -21,35 +22,58 @@ public class GlobalExceptionHandler { public ResponseEntity handleException(final Exception e) { log.error("[" + e.getClass() + "] : " + e.getMessage()); return ResponseEntity.internalServerError() - .body(new ExceptionResponse(-9999, "알 수 없는 서버 에러가 발생했습니다.")); + .body(new ExceptionResponse(100, "알 수 없는 서버 에러가 발생했습니다.")); + } + + @ExceptionHandler + public ResponseEntity handleMethodArgumentNotValidException( + final MethodArgumentNotValidException e + ) { + final List errorResponses = e.getBindingResult() + .getFieldErrors() + .stream() + .map(fieldError -> new ErrorResponse(fieldError.getField(), fieldError.getDefaultMessage())) + .toList(); + log.warn("[" + e.getClass() + "] " + errorResponses); + return ResponseEntity.badRequest() + .body(new ExceptionResponse(200, errorResponses.toString())); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity constraintViolationException(final ConstraintViolationException e) { + final List errorResponses = e.getConstraintViolations() + .stream() + .map(error -> new ErrorResponse(error.getPropertyPath().toString(), error.getMessage())) + .toList(); + log.warn("[" + e.getClass() + "] " + errorResponses); + return ResponseEntity.badRequest() + .body(new ExceptionResponse(201, errorResponses.toString())); } @ExceptionHandler public ResponseEntity handleMethodArgumentTypeMismatchException( final MethodArgumentTypeMismatchException e ) { - final String errorMessage = String.format( - "%s는 %s 타입이 필요합니다.", - e.getPropertyName(), - e.getRequiredType().getSimpleName() + final ErrorResponse errorResponse = new ErrorResponse( + e.getName(), + e.getRequiredType().getSimpleName() + " 타입으로 변환할 수 없는 요청입니다." ); - log.warn("[" + e.getClass() + "] : " + errorMessage); + log.warn("[" + e.getClass() + "] " + errorResponse); return ResponseEntity.badRequest() - .body(new ExceptionResponse(-9998, errorMessage)); + .body(new ExceptionResponse(202, errorResponse.toString())); } @ExceptionHandler - public ResponseEntity handleMethodArgumentNotValidException( - final MethodArgumentNotValidException e + public ResponseEntity handleMissingServletRequestParameterException( + final MissingServletRequestParameterException e ) { - final List errorMessages = e.getBindingResult() - .getAllErrors() - .stream() - .map(ObjectError::getDefaultMessage) - .toList(); - log.warn("[" + e.getClass() + "] : " + errorMessages); + final ErrorResponse errorResponse = new ErrorResponse( + e.getParameterName(), + "파라미터가 필요합니다." + ); + log.warn("[" + e.getClass() + "] " + errorResponse); return ResponseEntity.badRequest() - .body(new ExceptionResponse(-9997, errorMessages.toString())); + .body(new ExceptionResponse(203, errorResponse.toString())); } @ExceptionHandler @@ -59,6 +83,13 @@ public ResponseEntity handleBadRequestException(final BadRequ .body(ExceptionResponse.from(e)); } + @ExceptionHandler + public ResponseEntity handleImageException(final ImageException e) { + log.warn("[" + e.getClass() + "] : " + e.getMessage()); + return ResponseEntity.badRequest() + .body(ExceptionResponse.from(e)); + } + @ExceptionHandler public ResponseEntity handleNotFoundException(final NotFoundException e) { log.warn("[" + e.getClass() + "] : " + e.getMessage()); @@ -68,10 +99,6 @@ public ResponseEntity handleNotFoundException(final NotFoundE @ExceptionHandler public ResponseEntity handleMultipartException(final MultipartException e) { - System.out.println("================================"); - System.out.println("GlobalExceptionHandler.handleMultipartException"); - e.printStackTrace(); - log.warn("[" + e.getClass() + "] : " + e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ExceptionResponse.from(new BadRequestException(PostExceptionType.WRONG_IMAGE))); @@ -81,10 +108,6 @@ public ResponseEntity handleMultipartException(final Multipar public ResponseEntity handleMissingServletRequestPartException( final MissingServletRequestPartException e ) { - System.out.println("================================"); - System.out.println("GlobalExceptionHandler.handleMissingServletRequestPartException"); - e.printStackTrace(); - log.warn("[" + e.getClass() + "] : " + e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ExceptionResponse.from(new BadRequestException(PostExceptionType.WRONG_IMAGE))); diff --git a/backend/src/main/java/com/votogether/global/exception/ImageException.java b/backend/src/main/java/com/votogether/global/exception/ImageException.java new file mode 100644 index 000000000..a0e566375 --- /dev/null +++ b/backend/src/main/java/com/votogether/global/exception/ImageException.java @@ -0,0 +1,9 @@ +package com.votogether.global.exception; + +public class ImageException extends BaseException { + + public ImageException(final ExceptionType exceptionType) { + super(exceptionType); + } + +} diff --git a/backend/src/main/java/com/votogether/global/jwt/JwtAuthorizationArgumentResolver.java b/backend/src/main/java/com/votogether/global/jwt/JwtAuthorizationArgumentResolver.java index 7253f1634..71f2580da 100644 --- a/backend/src/main/java/com/votogether/global/jwt/JwtAuthorizationArgumentResolver.java +++ b/backend/src/main/java/com/votogether/global/jwt/JwtAuthorizationArgumentResolver.java @@ -34,6 +34,7 @@ public Member resolveArgument( ) throws JsonProcessingException { final String token = webRequest.getHeader(HttpHeaders.AUTHORIZATION); final String tokenWithoutType = tokenProcessor.resolveToken(token); + tokenProcessor.validateToken(tokenWithoutType); final TokenPayload tokenPayload = tokenProcessor.parseToken(tokenWithoutType); return memberService.findById(tokenPayload.memberId()); } diff --git a/backend/src/main/java/com/votogether/global/jwt/TokenProcessor.java b/backend/src/main/java/com/votogether/global/jwt/TokenProcessor.java index 0a3c824fc..8ab5e69a6 100644 --- a/backend/src/main/java/com/votogether/global/jwt/TokenProcessor.java +++ b/backend/src/main/java/com/votogether/global/jwt/TokenProcessor.java @@ -74,7 +74,6 @@ public String resolveToken(final String token) { } public TokenPayload parseToken(final String token) throws JsonProcessingException { - validateToken(token); final String[] chunks = token.split(TOKEN_DELIMITER); final String payload = new String(Decoders.BASE64.decode(chunks[1])); return objectMapper.readValue(payload, TokenPayload.class); diff --git a/backend/src/main/java/com/votogether/global/util/ImageUploader.java b/backend/src/main/java/com/votogether/global/util/ImageUploader.java deleted file mode 100644 index a381b648f..000000000 --- a/backend/src/main/java/com/votogether/global/util/ImageUploader.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.votogether.global.util; - -import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.global.exception.BadRequestException; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import org.springframework.web.multipart.MultipartFile; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class ImageUploader { - - public static String upload(final MultipartFile image) { - if (image.getOriginalFilename().contains("없는사진")) { - return ""; - } - - final long milli = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(); - - final String rootPath = new File("").getAbsolutePath() + File.separator; - final String imageDirPath = "static" + File.separator + "images" + File.separator; - final String imageFileName = milli + "_" + image.getOriginalFilename(); - - try { - File imageFolder = new File(rootPath + imageDirPath); - if (!imageFolder.exists()) { - imageFolder.mkdirs(); // Creates the directory if it does not exist - } - - Files.write(Paths.get(rootPath + imageDirPath + imageFileName), image.getBytes()); - } catch (IOException ignore) { - System.out.println("ImageUploader.upload"); - System.out.println("imageUrl = " + imageDirPath + imageFileName); - ignore.printStackTrace(); - throw new BadRequestException(PostExceptionType.WRONG_IMAGE); - } - - return imageDirPath + imageFileName; - } - -} diff --git a/backend/src/main/java/com/votogether/infra/image/ImageExceptionType.java b/backend/src/main/java/com/votogether/infra/image/ImageExceptionType.java new file mode 100644 index 000000000..d7beafd5b --- /dev/null +++ b/backend/src/main/java/com/votogether/infra/image/ImageExceptionType.java @@ -0,0 +1,25 @@ +package com.votogether.infra.image; + +import com.votogether.global.exception.ExceptionType; +import lombok.Getter; + +@Getter +public enum ImageExceptionType implements ExceptionType { + + INVALID_IMAGE_READ(1000, "이미지를 정상적으로 읽을 수 없습니다."), + IMAGE_NAME_BLANK(1001, "원본 이미지명이 존재하지 않습니다."), + IMAGE_FORMAT(1002, "이미지 확장자가 존재하지 않습니다."), + IMAGE_EXTENSION(1003, "이미지 파일만 업로드할 수 있습니다."), + IMAGE_TRANSFER(1004, "이미지 업로드에 실패했습니다."), + IMAGE_URL(1005, "이미지 URL을 확인할 수 없습니다."), + ; + + private final int code; + private final String message; + + ImageExceptionType(final int code, final String message) { + this.code = code; + this.message = message; + } + +} diff --git a/backend/src/main/java/com/votogether/infra/image/ImageName.java b/backend/src/main/java/com/votogether/infra/image/ImageName.java new file mode 100644 index 000000000..a10dc7e93 --- /dev/null +++ b/backend/src/main/java/com/votogether/infra/image/ImageName.java @@ -0,0 +1,40 @@ +package com.votogether.infra.image; + +import com.votogether.global.exception.ImageException; +import java.util.Set; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ImageName { + + private static final String DOT = "."; + private static final String UNDER_BAR = "_"; + private static final Set IMAGE_EXTENSIONS = Set.of("jpeg", "jpg", "png", "webp", "heic", "heif"); + + public static String from(final String originalFilename) { + final String extension = StringUtils.getFilenameExtension(originalFilename); + validateFileName(originalFilename); + validateExtension(extension); + final String fileBaseName = UUID.randomUUID().toString().substring(0, 8); + return fileBaseName + UNDER_BAR + System.currentTimeMillis() + DOT + extension; + } + + private static void validateFileName(final String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new ImageException(ImageExceptionType.IMAGE_NAME_BLANK); + } + } + + private static void validateExtension(final String extension) { + if (extension == null) { + throw new ImageException(ImageExceptionType.IMAGE_FORMAT); + } + if (!IMAGE_EXTENSIONS.contains(extension.toLowerCase())) { + throw new ImageException(ImageExceptionType.IMAGE_EXTENSION); + } + } + +} diff --git a/backend/src/main/java/com/votogether/infra/image/ImageUploader.java b/backend/src/main/java/com/votogether/infra/image/ImageUploader.java new file mode 100644 index 000000000..35bdff2d4 --- /dev/null +++ b/backend/src/main/java/com/votogether/infra/image/ImageUploader.java @@ -0,0 +1,11 @@ +package com.votogether.infra.image; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageUploader { + + String upload(final MultipartFile image); + + void delete(final String path); + +} diff --git a/backend/src/main/java/com/votogether/infra/image/LocalUploader.java b/backend/src/main/java/com/votogether/infra/image/LocalUploader.java new file mode 100644 index 000000000..a932bf2ae --- /dev/null +++ b/backend/src/main/java/com/votogether/infra/image/LocalUploader.java @@ -0,0 +1,90 @@ +package com.votogether.infra.image; + +import com.votogether.global.exception.ImageException; +import java.io.File; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class LocalUploader implements ImageUploader { + + private static final String SLASH = File.separator; + private static final String SYSTEM_PATH = System.getProperty("user.dir"); + + private final String url; + private final String uploadDirectory; + + public LocalUploader( + @Value("${image.upload_url}") final String url, + @Value("${image.upload_directory}") final String uploadDirectory + ) { + this.url = url; + this.uploadDirectory = uploadDirectory; + } + + public String upload(final MultipartFile image) { + final File directory = loadDirectory(getImageStorePath()); + if (isEmptyImage(image)) { + return null; + } + final String saveFileName = ImageName.from(image.getOriginalFilename()); + final File uploadPath = new File(directory, saveFileName); + transferFile(image, uploadPath); + return getImageFullPath(saveFileName); + } + + private String getImageStorePath() { + return SYSTEM_PATH + SLASH + uploadDirectory; + } + + private File loadDirectory(final String directoryLocation) { + final File directory = new File(directoryLocation); + if (!directory.exists()) { + directory.mkdirs(); + } + return directory; + } + + private boolean isEmptyImage(final MultipartFile multipartFile) { + return multipartFile == null || multipartFile.isEmpty(); + } + + private void transferFile(final MultipartFile file, final File uploadPath) { + try { + file.transferTo(uploadPath); + } catch (IOException e) { + throw new ImageException(ImageExceptionType.IMAGE_TRANSFER); + } + } + + private String getImageFullPath(final String fileName) { + return url + SLASH + uploadDirectory + SLASH + fileName; + } + + public void delete(final String path) { + if (path == null || path.isEmpty()) { + return; + } + final String deletePath = getImageLocalPath(path); + final File file = new File(deletePath); + deleteFile(file); + } + + private String getImageLocalPath(final String fullPath) { + final int urlIndex = fullPath.lastIndexOf(url); + if (urlIndex == -1) { + throw new ImageException(ImageExceptionType.IMAGE_URL); + } + final int urlNextIndex = urlIndex + url.length(); + return SYSTEM_PATH + fullPath.substring(urlNextIndex); + } + + private void deleteFile(final File file) { + if (file.exists()) { + file.delete(); + } + } + +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7cd6afcc3..854cc0627 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -41,6 +41,10 @@ server: forward-headers-strategy: FRAMEWORK tomcat: max-http-form-post-size: 35MB + accept-count: ${ACCEPT_COUNT} + max-connections: ${MAX_CONNECTIONS} + threads: + max: ${THREADS_MAX} springdoc: swagger-ui: @@ -63,3 +67,7 @@ jwt: secret-key: ${SECRET_KEY} access-expiration-time: ${ACCESS_EXPIRATION_TIME} refresh-expiration-time: ${REFRESH_EXPIRATION_TIME} + +image: + upload_url: ${IMAGE_UPLOAD_URL} + upload_directory: ${IMAGE_UPLOAD_DIRECTORY} diff --git a/backend/src/main/resources/log4j2-dev.xml b/backend/src/main/resources/log4j2-dev.xml index d80bc2e55..617045c18 100644 --- a/backend/src/main/resources/log4j2-dev.xml +++ b/backend/src/main/resources/log4j2-dev.xml @@ -34,14 +34,6 @@ - - - - - - - - diff --git a/backend/src/main/resources/log4j2-local.xml b/backend/src/main/resources/log4j2-local.xml index 9f6d08bfa..a29bf1892 100644 --- a/backend/src/main/resources/log4j2-local.xml +++ b/backend/src/main/resources/log4j2-local.xml @@ -21,14 +21,6 @@ - - - - - - - - diff --git a/backend/src/test/java/com/votogether/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/votogether/domain/member/service/MemberServiceTest.java index bba1e64e5..608b90518 100644 --- a/backend/src/test/java/com/votogether/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/votogether/domain/member/service/MemberServiceTest.java @@ -14,7 +14,6 @@ import com.votogether.domain.member.repository.MemberCategoryRepository; import com.votogether.domain.member.repository.MemberRepository; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.domain.post.entity.comment.Comment; import com.votogether.domain.post.repository.CommentRepository; import com.votogether.domain.post.repository.PostRepository; @@ -24,9 +23,7 @@ import com.votogether.global.exception.BadRequestException; import com.votogether.test.annotation.ServiceTest; import com.votogether.test.fixtures.MemberFixtures; -import com.votogether.test.persister.MemberTestPersister; import com.votogether.test.persister.PostTestPersister; -import com.votogether.test.persister.VoteTestPersister; import jakarta.persistence.EntityManager; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -62,15 +59,9 @@ class MemberServiceTest { @Autowired CommentRepository commentRepository; - @Autowired - MemberTestPersister memberTestPersister; - @Autowired PostTestPersister postTestPersister; - @Autowired - VoteTestPersister voteTestPersister; - @Autowired EntityManager em; @@ -263,14 +254,10 @@ void successWithPost() { // given Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() .writer(member) - .postBody(postBody) + .title("title") + .content("content") .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) .build(); @@ -293,14 +280,10 @@ void deleteOnlyMember() { Member member = memberRepository.save(MemberFixtures.MALE_20.get()); Member writer = memberRepository.save(MemberFixtures.FEMALE_20.get()); - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() .writer(writer) - .postBody(postBody) + .title("title") + .content("content") .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) .build(); @@ -312,7 +295,7 @@ void deleteOnlyMember() { // then assertAll( () -> assertThat(memberRepository.findAll()).hasSize(1), - () -> assertThat(memberRepository.findById(writer.getId()).get()).isEqualTo(writer), + () -> assertThat(memberRepository.findById(writer.getId())).contains(writer), () -> assertThat(postRepository.findAll()).hasSize(1) ); } @@ -378,14 +361,10 @@ void deleteWithReportedPost() { Member member = memberRepository.save(MemberFixtures.MALE_20.get()); Member reporter = memberRepository.save(MemberFixtures.MALE_10.get()); - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() .writer(member) - .postBody(postBody) + .title("title") + .content("content") .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) .build(); @@ -417,20 +396,16 @@ void deleteWithReportedComment() { Member member = memberRepository.save(MemberFixtures.MALE_20.get()); Member reporter = memberRepository.save(MemberFixtures.MALE_10.get()); - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() .writer(reporter) - .postBody(postBody) + .title("title") + .content("content") .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) .build(); Comment comment = Comment.builder() .post(post) - .member(member) + .writer(member) .content("댓글입니다.") .build(); diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostCommandControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostCommandControllerTest.java new file mode 100644 index 000000000..873389f94 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/controller/PostCommandControllerTest.java @@ -0,0 +1,435 @@ +package com.votogether.domain.post.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.request.post.PostCreateRequest; +import com.votogether.domain.post.dto.request.post.PostUpdateRequest; +import com.votogether.domain.post.service.PostCommandService; +import com.votogether.global.exception.GlobalExceptionHandler; +import com.votogether.test.ControllerTest; +import io.restassured.http.ContentType; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import java.io.File; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@WebMvcTest(PostCommandController.class) +class PostCommandControllerTest extends ControllerTest { + + @MockBean + PostCommandService postCommandService; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + RestAssuredMockMvc.standaloneSetup( + MockMvcBuilders + .standaloneSetup(new PostCommandController(postCommandService)) + .setControllerAdvice(GlobalExceptionHandler.class) + ); + RestAssuredMockMvc.webAppContextSetup(webApplicationContext); + } + + @Nested + @DisplayName("게시글 작성") + class CreatePost { + + @Test + @DisplayName("게시글 작성에 성공하면 201 응답을 반환한다.") + void return201success() throws Exception { + // given + mockingAuthArgumentResolver(); + given(postCommandService.createPost(any(PostCreateRequest.class), any(Member.class))).willReturn(1L); + String filePath = "src/test/resources/images/"; + String fileName = "testImage1.PNG"; + File file = new File(filePath + fileName); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", "content") + .multiPart("contentImage", file) + .param("postOptions[0].content", "Option Content") + .multiPart("postOptions[0].optionImage", file) + .param("deadline", LocalDateTime.now().toString()) + .when().post("/posts") + .then().log().all() + .status(HttpStatus.CREATED) + .header("Location", "/posts/1"); + } + + @Test + @DisplayName("카테고리 ID가 존재하지 않으면 400 응답을 반환한다.") + void return400nullCategory() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().post("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 카테고리 ID가 등록되지 않았습니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 제목이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyTitle(String title) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("categoryIds", List.of(1L)) + .param("title", title) + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().post("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 제목이 존재하지 않거나 공백만 존재합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 내용이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyContent(String content) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("categoryIds", List.of(1L)) + .param("title", "title") + .param("content", content) + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().post("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 내용이 존재하지 않거나 공백만 존재합니다.")); + } + + @Test + @DisplayName("게시글 마감 시간이 등록되지 않았으면 400 응답을 반환한다.") + void emptyDeadline() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("categoryIds", List.of(1L)) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .when().post("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 마감시간을 등록하지 않았습니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 옵션 내용이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyOptionContent(String optionContent) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(ContentType.MULTIPART) + .param("categoryIds", List.of(1L)) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", optionContent) + .param("deadline", LocalDateTime.now().toString()) + .when().post("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 옵션 내용이 존재하지 않거나 공백만 존재합니다.")); + } + + } + + @Nested + @DisplayName("게시글 수정") + class UpdatePost { + + @Test + @DisplayName("게시글 수정에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + willDoNothing().given(postCommandService) + .updatePost(anyLong(), any(PostUpdateRequest.class), any(Member.class)); + String filePath = "src/test/resources/images/"; + String fileName = "testImage1.PNG"; + File file = new File(filePath + fileName); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", "content") + .multiPart("contentImage", file) + .param("postOptions[0].content", "Option Content") + .multiPart("postOptions[0].optionImage", file) + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.OK); + } + + @Test + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/-1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + @Test + @DisplayName("카테고리 ID가 존재하지 않으면 400 응답을 반환한다.") + void return400EmptyCategory() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 카테고리 ID가 등록되지 않았습니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 제목이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyTitle(String title) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", title) + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 제목이 존재하지 않거나 공백만 존재합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 내용이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyContent(String content) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", content) + .param("postOptions[0].content", "Option Content") + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 내용이 존재하지 않거나 공백만 존재합니다.")); + } + + @Test + @DisplayName("게시글 마감 시간이 등록되지 않았으면 400 응답을 반환한다.") + void emptyDeadline() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", "Option Content") + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 마감시간을 등록하지 않았습니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + @DisplayName("게시글 옵션 내용이 존재하지 않거나 공백이라면 400 응답을 반환한다.") + void emptyOptionContent(String optionContent) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("categoryIds", List.of(1L, 2L)) + .param("title", "title") + .param("content", "content") + .param("postOptions[0].content", optionContent) + .param("deadline", LocalDateTime.now().toString()) + .when().put("/posts/1") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("게시글 옵션 내용이 존재하지 않거나 공백만 존재합니다.")); + } + + } + + @Nested + @DisplayName("게시글 조기 마감") + class ClosePostEarly { + + @Test + @DisplayName("게시글 조기 마감에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + willDoNothing().given(postCommandService).closePostEarly(anyLong(), any(Member.class)); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().patch("/posts/{postId}/close", 1) + .then().log().all() + .status(HttpStatus.OK); + } + + @Test + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().patch("/posts/{postId}/close", -1) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("게시글 삭제") + class DeletePost { + + @Test + @DisplayName("게시글 삭제에 성공하면 204 응답을 반환한다.") + void return204success() throws Exception { + // given + mockingAuthArgumentResolver(); + willDoNothing().given(postCommandService).deletePost(anyLong(), any(Member.class)); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().delete("/posts/{postId}", 1) + .then().log().all() + .status(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().delete("/posts/{postId}", -1) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java index 3d51bb363..222c70b84 100644 --- a/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java +++ b/backend/src/test/java/com/votogether/domain/post/controller/PostCommentControllerTest.java @@ -5,22 +5,17 @@ import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.service.MemberService; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; +import com.votogether.domain.post.dto.request.comment.CommentCreateRequest; import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest; import com.votogether.domain.post.dto.response.comment.CommentResponse; -import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.dto.response.comment.CommentWriterResponse; import com.votogether.domain.post.service.PostCommentService; import com.votogether.global.exception.GlobalExceptionHandler; -import com.votogether.global.jwt.JwtAuthenticationFilter; -import com.votogether.global.jwt.TokenPayload; -import com.votogether.global.jwt.TokenProcessor; -import com.votogether.test.fixtures.MemberFixtures; +import com.votogether.test.ControllerTest; import io.restassured.common.mapper.TypeRef; import io.restassured.module.mockmvc.RestAssuredMockMvc; import java.time.LocalDateTime; @@ -37,172 +32,178 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; @WebMvcTest(PostCommentController.class) -class PostCommentControllerTest { +class PostCommentControllerTest extends ControllerTest { @MockBean PostCommentService postCommentService; - @MockBean - MemberService memberService; - - @MockBean - TokenProcessor tokenProcessor; - @BeforeEach - void setUp() { + void setUp(WebApplicationContext webApplicationContext) { RestAssuredMockMvc.standaloneSetup( MockMvcBuilders .standaloneSetup(new PostCommentController(postCommentService)) .setControllerAdvice(GlobalExceptionHandler.class) - .addFilters(new JwtAuthenticationFilter(tokenProcessor)) ); + RestAssuredMockMvc.webAppContextSetup(webApplicationContext); } @Nested - @DisplayName("게시글 댓글 등록 시 ") - class CreateComment { + @DisplayName("게시글 댓글 목록 조회") + class GetComments { - @ParameterizedTest - @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("ID로 변환할 수 없는 타입이라면 400을 응답한다.") - void invalidIDType(String id) throws Exception { + @Test + @DisplayName("정상적인 요청이라면 게시글 댓글 목록과 200 응답을 반환한다.") + void getComments() { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + List response = List.of( + new CommentResponse( + 1L, + new CommentWriterResponse(1L, "votogetherA"), + "helloA", + LocalDateTime.now(), + LocalDateTime.now() + ), + new CommentResponse( + 2L, + new CommentWriterResponse(2L, "votogetherB"), + "helloB", + LocalDateTime.now(), + LocalDateTime.now() + ) + ); + given(postCommentService.getComments(anyLong())).willReturn(response); - // when, then + // when + List result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().get("/posts/{postId}/comments", 1L) + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison() + .ignoringFieldsOfTypes(LocalDateTime.class) + .isEqualTo(response); + } + + @ParameterizedTest + @ValueSource(strings = {"@", "a", "가"}) + @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") + void invalidPostIDType(String postId) { + // given, when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().post("/posts/{postId}/comments", id) + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().get("/posts/{postId}/comments", postId) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", equalTo("postId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); } @ParameterizedTest - @NullAndEmptySource - @DisplayName("댓글 내용이 존재하지 않으면 400을 응답한다.") - void emptyContent(String content) throws Exception { - // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); - - CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest(content); - - // when, then + @ValueSource(longs = {-1L, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositivePostId(Long postId) { + // given, when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .contentType(MediaType.APPLICATION_JSON) - .body(commentRegisterRequest) - .when().post("/posts/{postId}/comments", 1) + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().get("/posts/{postId}/comments", postId) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9997)) - .body("message", containsString("댓글 내용은 존재해야 합니다.")); + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); } + } + + @Nested + @DisplayName("게시글 댓글 등록 시 ") + class CreateComment { + @Test - @DisplayName("댓글을 정상적으로 등록하면 201을 응답한다.") + @DisplayName("정상적인 요청이라면 댓글을 등록하고 201 응답을 반환한다.") void createComment() throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); - - CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest("댓글입니다."); + mockingAuthArgumentResolver(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("댓글입니다."); willDoNothing().given(postCommentService) - .createComment(any(Member.class), anyLong(), any(CommentRegisterRequest.class)); + .createComment(anyLong(), any(CommentCreateRequest.class), any(Member.class)); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .contentType(MediaType.APPLICATION_JSON) - .body(commentRegisterRequest) + .body(commentCreateRequest) .when().post("/posts/{postId}/comments", 1) .then().log().all() .status(HttpStatus.CREATED); } - } - - @Nested - @DisplayName("게시글 댓글 목록 조회") - class GetComments { - @ParameterizedTest @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") - void invalidPostIDType(String postId) throws Exception { + @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") + void invalidIDType(String postId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("hello"); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().get("/posts/{postId}/comments", postId) + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(commentCreateRequest) + .when().post("/posts/{postId}/comments", postId) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", containsString("postId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); } - @Test - @DisplayName("정상적인 요청이라면 게시글 댓글 목록을 조회한다.") - void getComments() throws Exception { + @ParameterizedTest + @ValueSource(longs = {-1L, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositivePostId(Long postId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); - - Member memberA = MemberFixtures.MALE_20.get(); - Member memberB = MemberFixtures.FEMALE_20.get(); - ReflectionTestUtils.setField(memberA, "id", 1L); - ReflectionTestUtils.setField(memberB, "id", 2L); - - Comment commentA = Comment.builder() - .member(memberA) - .content("commentA") - .build(); - Comment commentB = Comment.builder() - .member(memberB) - .content("commentA") - .build(); - LocalDateTime now = LocalDateTime.now(); - ReflectionTestUtils.setField(commentA, "createdAt", now); - ReflectionTestUtils.setField(commentB, "createdAt", now); - - CommentResponse commentResponseA = CommentResponse.from(commentA); - CommentResponse commentResponseB = CommentResponse.from(commentB); - given(postCommentService.getComments(anyLong())).willReturn(List.of(commentResponseA, commentResponseB)); + mockingAuthArgumentResolver(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("hello"); - // when - List response = RestAssuredMockMvc.given().log().all() - //.headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().get("/posts/{postId}/comments", 1L) + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(commentCreateRequest) + .when().post("/posts/{postId}/comments", postId) .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new TypeRef>() { - }); + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } - // then - assertThat(response).usingRecursiveComparison() - .ignoringFieldsOfTypes(LocalDateTime.class) - .isEqualTo(List.of(commentResponseA, commentResponseB)); + @ParameterizedTest + @NullAndEmptySource + @DisplayName("댓글 내용이 존재하지 않으면 400 응답을 반환한다.") + void emptyContent(String content) throws Exception { + // given + mockingAuthArgumentResolver(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest(content); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(commentCreateRequest) + .when().post("/posts/{postId}/comments", 1) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(200)) + .body("message", containsString("댓글 내용은 존재해야 합니다.")); } } @@ -211,72 +212,122 @@ void getComments() throws Exception { @DisplayName("게시글 댓글 수정") class UpdateComment { + @Test + @DisplayName("정상적인 요청이라면 댓글을 수정하고 200 응답을 반환한다.") + void updateComment() throws Exception { + // given + mockingAuthArgumentResolver(); + CommentUpdateRequest request = new CommentUpdateRequest("hello"); + willDoNothing().given(postCommentService) + .updateComment(anyLong(), anyLong(), any(CommentUpdateRequest.class), any(Member.class)); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when().put("/posts/{postId}/comments/{commentId}", 1L, 1L) + .then().log().all() + .status(HttpStatus.OK); + } + @ParameterizedTest @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") void invalidPostIDType(String postId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .body(request) .when().put("/posts/{postId}/comments/{commentId}", postId, 1L) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", containsString("postId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); } @ParameterizedTest @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("댓글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + @DisplayName("댓글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") void invalidCommentIDType(String commentId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .body(request) .when().put("/posts/{postId}/comments/{commentId}", 1L, commentId) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", containsString("commentId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositivePostId(Long postId) throws Exception { + // given + mockingAuthArgumentResolver(); + CommentUpdateRequest commentCreateRequest = new CommentUpdateRequest("hello"); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(commentCreateRequest) + .when().put("/posts/{postId}/comments/{commentId}", postId, 1L) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, 0}) + @DisplayName("댓글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositiveCommentId(Long commentId) throws Exception { + // given + mockingAuthArgumentResolver(); + CommentUpdateRequest commentCreateRequest = new CommentUpdateRequest("hello"); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .body(commentCreateRequest) + .when().put("/posts/{postId}/comments/{commentId}", 1L, commentId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("댓글 ID는 양의 정수만 가능합니다.")); } @ParameterizedTest @NullAndEmptySource - @DisplayName("수정할 댓글 내용이 비어있거나 존재하지 않으면 400을 응답한다.") + @DisplayName("수정할 댓글 내용이 비어있거나 존재하지 않으면 400 응답을 반환한다.") void nullAndEmptyCommentContent(String content) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); CommentUpdateRequest request = new CommentUpdateRequest(content); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .body(request) .when().put("/posts/{postId}/comments/{commentId}", 1L, 1L) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9997)) + .body("code", equalTo(200)) .body("message", containsString("댓글 내용은 존재해야 합니다.")); } @@ -286,63 +337,87 @@ void nullAndEmptyCommentContent(String content) throws Exception { @DisplayName("게시글 댓글 삭제") class DeleteComment { + @Test + @DisplayName("정상적인 요청이라면 댓글을 삭제하고 204응답을 반환한다.") + void deleteComment() throws Exception { + // given + mockingAuthArgumentResolver(); + willDoNothing().given(postCommentService).deleteComment(anyLong(), anyLong(), any(Member.class)); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().delete("/posts/{postId}/comments/{commentId}", 1L, 1L) + .then().log().all() + .status(HttpStatus.NO_CONTENT); + } + @ParameterizedTest @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + @DisplayName("게시글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") void invalidPostIDType(String postId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .when().delete("/posts/{postId}/comments/{commentId}", postId, 1L) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", containsString("postId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); } @ParameterizedTest @ValueSource(strings = {"@", "a", "가"}) - @DisplayName("댓글 ID가 Long 타입으로 변환할 수 없는 값이라면 400을 응답한다.") + @DisplayName("댓글 ID가 Long 타입으로 변환할 수 없는 값이라면 400 응답을 반환한다.") void invalidCommentIDType(String commentId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) .when().delete("/posts/{postId}/comments/{commentId}", 1L, commentId) .then().log().all() .status(HttpStatus.BAD_REQUEST) - .body("code", equalTo(-9998)) - .body("message", containsString("commentId는 Long 타입이 필요합니다.")); + .body("code", equalTo(202)) + .body("message", containsString("Long 타입으로 변환할 수 없는 요청입니다.")); } - @Test - @DisplayName("댓글을 정상적으로 삭제하면 204를 응답한다.") - void deleteComment() throws Exception { + @ParameterizedTest + @ValueSource(longs = {-1L, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositivePostId(Long postId) throws Exception { // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + mockingAuthArgumentResolver(); - willDoNothing().given(postCommentService).deleteComment(anyLong(), anyLong(), any(Member.class)); + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().delete("/posts/{postId}/comments/{commentId}", postId, 1L) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, 0}) + @DisplayName("댓글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void notPositiveCommentId(Long commentId) throws Exception { + // given + mockingAuthArgumentResolver(); // when, then RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().delete("/posts/{postId}/comments/{commentId}", 1L, 1L) + .headers(HttpHeaders.AUTHORIZATION, BEARER_TOKEN) + .when().delete("/posts/{postId}/comments/{commentId}", 1L, commentId) .then().log().all() - .status(HttpStatus.NO_CONTENT); + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("댓글 ID는 양의 정수만 가능합니다.")); } } diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostControllerTest.java deleted file mode 100644 index ae3d1db20..000000000 --- a/backend/src/test/java/com/votogether/domain/post/controller/PostControllerTest.java +++ /dev/null @@ -1,903 +0,0 @@ -package com.votogether.domain.post.controller; - -import static com.votogether.test.fixtures.MemberFixtures.MALE_30; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.startsWith; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.BDDMockito.given; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.service.MemberService; -import com.votogether.domain.post.dto.request.post.PostCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest; -import com.votogether.domain.post.dto.request.post.PostUpdateRequest; -import com.votogether.domain.post.dto.response.post.PostDetailResponse; -import com.votogether.domain.post.dto.response.post.PostRankingResponse; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.dto.response.post.PostSummaryResponse; -import com.votogether.domain.post.dto.response.post.WriterResponse; -import com.votogether.domain.post.dto.response.vote.VoteCountForAgeGroupResponse; -import com.votogether.domain.post.dto.response.vote.VoteDetailResponse; -import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.post.service.PostService; -import com.votogether.global.exception.GlobalExceptionHandler; -import com.votogether.global.jwt.TokenPayload; -import com.votogether.global.jwt.TokenProcessor; -import com.votogether.test.fixtures.MemberFixtures; -import io.restassured.http.ContentType; -import io.restassured.module.mockmvc.RestAssuredMockMvc; -import io.restassured.module.mockmvc.response.MockMvcResponse; -import io.restassured.response.ExtractableResponse; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -@WebMvcTest(PostController.class) -class PostControllerTest { - - @Autowired - ObjectMapper mapper; - - @MockBean - TokenProcessor tokenProcessor; - - @MockBean - MemberService memberService; - - @MockBean - PostService postService; - - @BeforeEach - void setUp(final WebApplicationContext webApplicationContext) { - RestAssuredMockMvc.standaloneSetup( - MockMvcBuilders - .standaloneSetup(new PostController(postService)) - .setControllerAdvice(GlobalExceptionHandler.class) - ); - RestAssuredMockMvc.webAppContextSetup(webApplicationContext); - } - - @Test - @DisplayName("게시글을 작성한다") - void save() throws IOException { - // given - PostOptionCreateRequest postOptionCreateRequest1 = PostOptionCreateRequest.builder() - .content("optionContent1") - .build(); - - PostOptionCreateRequest postOptionCreateRequest2 = PostOptionCreateRequest.builder() - .content("optionContent2") - .build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(0L)) - .title("title") - .content("content") - .deadline(LocalDateTime.now().plusDays(2)) - .postOptions(List.of(postOptionCreateRequest1, postOptionCreateRequest2)) - .build(); - - String fileName1 = "testImage1.PNG"; - String resultFileName1 = "testResultImage1.PNG"; - String filePath1 = "src/test/resources/images/" + fileName1; - File file1 = new File(filePath1); - - String fileName2 = "testImage2.PNG"; - String resultFileName2 = "testResultImage2.PNG"; - String filePath2 = "src/test/resources/images/" + fileName2; - File file2 = new File(filePath2); - - String fileName3 = "testImage3.PNG"; - String resultFileName3 = "testResultImage3.PNG"; - String filePath3 = "src/test/resources/images/" + fileName3; - File file3 = new File(filePath3); - - String postRequestJson = mapper.writeValueAsString(postCreateRequest); - - long savedPostId = 1L; - given(postService.save(any(), any(), anyList(), anyList())).willReturn(savedPostId); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when, then - String locationStartsWith = "/posts/"; - ExtractableResponse response = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .contentType(ContentType.MULTIPART) - .multiPart("request", postRequestJson, "application/json") - .multiPart("contentImages", resultFileName3, new FileInputStream(file3), "image/png") - .multiPart("optionImages", resultFileName1, new FileInputStream(file1), "image/png") - .multiPart("optionImages", resultFileName2, new FileInputStream(file2), "image/png") - .when().post("/posts") - .then().log().all() - .status(HttpStatus.CREATED) - .header("Location", startsWith(locationStartsWith)) - .extract(); - - String postId = response.header("Location").substring(locationStartsWith.length()); - assertThat(Long.parseLong(postId)).isEqualTo(savedPostId); - } - - @Test - @DisplayName("게시글을 등록 시, 유효성 검증에 위배되는 데이터를 전달하면 예외를 던진다.") - void throwExceptionBlankTitle() throws IOException { - // given - PostOptionCreateRequest postOptionCreateRequest1 = PostOptionCreateRequest.builder() - .content("optionContent1") - .build(); - - PostOptionCreateRequest postOptionCreateRequest2 = PostOptionCreateRequest.builder() - .content("optionContent2") - .build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(0L)) - .content("c".repeat(1001)) - .deadline(LocalDateTime.now().plusDays(2)) - .postOptions(List.of(postOptionCreateRequest1, postOptionCreateRequest2)) - .build(); - - String fileName1 = "testImage1.PNG"; - String resultFileName1 = "testResultImage1.PNG"; - String filePath1 = "src/test/resources/images/" + fileName1; - File file1 = new File(filePath1); - - String fileName2 = "testImage2.PNG"; - String resultFileName2 = "testResultImage2.PNG"; - String filePath2 = "src/test/resources/images/" + fileName2; - File file2 = new File(filePath2); - - String fileName3 = "testImage3.PNG"; - String resultFileName3 = "testResultImage3.PNG"; - String filePath3 = "src/test/resources/images/" + fileName3; - File file3 = new File(filePath3); - - String postRequestJson = mapper.writeValueAsString(postCreateRequest); - - long savedPostId = 1L; - given(postService.save(any(), any(), anyList(), anyList())).willReturn(savedPostId); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - ExtractableResponse response = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .contentType(ContentType.MULTIPART) - .multiPart("request", postRequestJson, "application/json") - .multiPart("contentImages", resultFileName3, new FileInputStream(file3), "image/png") - .multiPart("optionImages", resultFileName1, new FileInputStream(file1), "image/png") - .multiPart("optionImages", resultFileName2, new FileInputStream(file2), "image/png") - .when().post("/posts") - .then().log().all() - .extract(); - - // then - final String message = response.body().jsonPath().get("message").toString(); - assertAll( - () -> assertThat(message).contains("제목을 입력해주세요.", "내용은 최대 1000자까지 입력 가능합니다."), - () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()) - ); - } - - @Nested - @DisplayName("전체 게시글 목록 조회에서") - class GetAllPost { - - @Test - @DisplayName("page에 숫자가 아닌 다른 값이 들어온 경우 400을 반환한다.") - void invalidPage() throws Exception { - // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when, then - RestAssuredMockMvc - .given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", "abc") - .param("postClosingType", PostClosingType.CLOSED) - .param("postSortType", PostSortType.LATEST) - .when().get("/posts") - .then().log().all() - .status(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("PageClosingType이 아닌 다른 값이 들어온 경우 400을 반환한다.") - void invalidPostClosingType() throws Exception { - // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when, then - RestAssuredMockMvc - .given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", 0) - .param("postClosingType", "abc") - .param("postSortType", PostSortType.LATEST) - .when().get("/posts") - .then().log().all() - .status(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("PostSortType이 아닌 다른 값이 들어온 경우 400을 반환한다.") - void invalidPostSortType() throws Exception { - // given - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when, then - RestAssuredMockMvc - .given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", 0) - .param("postClosingType", PostClosingType.CLOSED) - .param("postSortType", "abc") - .when().get("/posts") - .then().log().all() - .status(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("정렬 유형, 마감 유형, 카테고리로 모든 게시물 조회한다") - void getAllPostBySortTypeAndClosingTypeAndCategoryId() throws Exception { - // given - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L)) - .build(); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - given(postService.getAllPostBySortTypeAndClosingTypeAndCategoryId( - eq(0), - eq(PostClosingType.PROGRESS), - eq(PostSortType.LATEST), - anyLong(), - any(Member.class) - ) - ).willReturn(List.of(PostResponse.of(post, MALE_30.get()))); - - // when - List responses = RestAssuredMockMvc - .given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", 0) - .param("postClosingType", PostClosingType.PROGRESS) - .param("postSortType", PostSortType.LATEST) - .param("category", 1L) - .when().get("/posts") - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - // then - assertAll( - () -> assertThat(responses).isNotEmpty(), - () -> assertThat(responses).hasSize(1) - ); - } - - } - - - @Nested - @DisplayName("비회원 게시글 목록 조회") - class GetPostsGuest { - - @Test - @DisplayName("마감 시간 타입으로 변환할 수 없으면 400 상태를 응답한다.") - void invalidPostClosingType() { - RestAssuredMockMvc.given().log().all() - .param("page", 0) - .param("postClosingType", "hello") - .param("postSortType", PostSortType.LATEST) - .when().get("/posts/guest") - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("정렬 타입으로 변환할 수 없으면 400 상태를 반환한다.") - void invalidPostSortType() { - RestAssuredMockMvc.given().log().all() - .param("page", 0) - .param("postClosingType", PostClosingType.ALL) - .param("postSortType", "hello") - .when().get("/posts/guest") - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("카테고리가 없는 올바른 조회 요청이라면 게시글 목록 응답과 200 상태를 반환한다.") - void getPostsGuestWithoutCategory() { - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L)) - .build(); - - given(postService.getPostsGuest(anyInt(), any(PostClosingType.class), any(PostSortType.class), isNull())) - .willReturn(List.of(PostResponse.forGuest(post))); - - RestAssuredMockMvc.given().log().all() - .param("page", 0) - .param("postClosingType", PostClosingType.ALL) - .param("postSortType", PostSortType.LATEST) - .when().get("/posts/guest") - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.OK); - } - - @Test - @DisplayName("카테고리가 있는 올바른 조회 요청이라면 게시글 목록 응답과 200 상태를 반환한다.") - void getPostsGuestWithCategory() { - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L)) - .build(); - - given(postService.getPostsGuest(anyInt(), any(PostClosingType.class), any(PostSortType.class), anyLong())) - .willReturn(List.of(PostResponse.forGuest(post))); - - RestAssuredMockMvc.given().log().all() - .param("page", 0) - .param("postClosingType", PostClosingType.ALL) - .param("postSortType", PostSortType.LATEST) - .param("category", 1L) - .when().get("/posts/guest") - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.OK); - } - - } - - @Test - @DisplayName("한 게시글의 상세를 조회한다.") - void getPost() throws JsonProcessingException { - // given - long postId = 0L; - Member writer = MALE_30.get(); - LocalDateTime deadline = LocalDateTime.now().plusDays(3L); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(deadline) - .build(); - - Member member = MemberFixtures.MALE_20.get(); - given(postService.getPostById(postId, member)).willReturn(PostDetailResponse.of(post, member)); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - String responseBody = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().get("/posts/{postId}", postId) - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.OK) - .extract().asString(); - - PostDetailResponse response = mapper.readValue(responseBody, new TypeReference<>() { - }); - - // then - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - - assertAll( - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), - () -> assertThat(response.deadline()).isEqualTo(deadline.truncatedTo(ChronoUnit.MINUTES)), - () -> assertThat(writerResponse.id()).isEqualTo(member.getId()), - () -> assertThat(writerResponse.nickname()).isEqualTo("user9"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isZero() - ); - } - - @Test - @DisplayName("비회원이 한 게시글을 상세 조회한다.") - void getPostByGuest() { - // given - long postId = 0L; - Member writer = MALE_30.get(); - LocalDateTime deadline = LocalDateTime.now().plusDays(3L); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(deadline) - .build(); - - given(postService.getPostById(postId, null)).willReturn(PostDetailResponse.of(post, null)); - - // when - PostDetailResponse response = RestAssuredMockMvc.given().log().all() - .when().get("/posts/{postId}/guest", postId) - .then().log().all() - .contentType(ContentType.JSON) - .status(HttpStatus.OK) - .extract() - .as(PostDetailResponse.class); - - // then - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - - assertAll( - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), - () -> assertThat(response.deadline()).isEqualTo(deadline.truncatedTo(ChronoUnit.MINUTES)), - () -> assertThat(writerResponse.nickname()).isEqualTo("user9"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isEqualTo(-1) - ); - } - - @Test - @DisplayName("게시글에 대한 전체 투표 통계를 조회한다.") - void getVoteStatistics() throws Exception { - // given - VoteOptionStatisticsResponse response = new VoteOptionStatisticsResponse( - 17, - 10, - 7, - List.of( - new VoteCountForAgeGroupResponse("10대 미만", 2, 1, 1), - new VoteCountForAgeGroupResponse("10대", 3, 1, 2), - new VoteCountForAgeGroupResponse("20대", 2, 2, 0), - new VoteCountForAgeGroupResponse("30대", 5, 3, 2), - new VoteCountForAgeGroupResponse("40대", 1, 1, 0), - new VoteCountForAgeGroupResponse("50대", 0, 0, 0), - new VoteCountForAgeGroupResponse("60대 이상", 4, 2, 2) - ) - ); - given(postService.getVoteStatistics(anyLong(), any(Member.class))).willReturn(response); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - VoteOptionStatisticsResponse result = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().get("/posts/{postId}/options", 1) - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(VoteOptionStatisticsResponse.class); - - // then - assertThat(result).usingRecursiveComparison().isEqualTo(response); - } - - @Test - @DisplayName("게시글 투표 옵션에 대한 투표 통계를 조회한다.") - void getVoteOptionStatistics() throws Exception { - // given - VoteOptionStatisticsResponse response = new VoteOptionStatisticsResponse( - 17, - 10, - 7, - List.of( - new VoteCountForAgeGroupResponse("10대 미만", 2, 1, 1), - new VoteCountForAgeGroupResponse("10대", 3, 1, 2), - new VoteCountForAgeGroupResponse("20대", 2, 2, 0), - new VoteCountForAgeGroupResponse("30대", 5, 3, 2), - new VoteCountForAgeGroupResponse("40대", 1, 1, 0), - new VoteCountForAgeGroupResponse("50대", 0, 0, 0), - new VoteCountForAgeGroupResponse("60대 이상", 4, 2, 2) - ) - ); - given(postService.getVoteOptionStatistics(anyLong(), anyLong(), any(Member.class))).willReturn(response); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - VoteOptionStatisticsResponse result = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().get("/posts/{postId}/options/{optionId}", 1, 1) - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(VoteOptionStatisticsResponse.class); - - // then - assertThat(result).usingRecursiveComparison().isEqualTo(response); - } - - @Test - @DisplayName("회원본인이 투표한 게시글 목록을 조회한다.") - void getPostsVotedByMember() throws Exception { - // given - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) - .build(); - - PostResponse postResponse = PostResponse.of(post, MALE_30.get()); - - given(postService.getPostsVotedByMember(anyInt(), any(), any(), any(Member.class))) - .willReturn(List.of(postResponse)); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - List result = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", 0) - .param("postClosingType", PostClosingType.PROGRESS) - .param("postSortType", PostSortType.LATEST) - .when().get("/posts/votes/me") - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - // then - assertThat(result.get(0)).usingRecursiveComparison().isEqualTo(postResponse); - } - - @Test - @DisplayName("게시글을 조기 마감한다.") - void postClosedEarly() throws Exception { - // given - long postId = 1L; - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when - ExtractableResponse response = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .when().patch("/posts/{postId}/close", postId) - .then().log().all() - .extract(); - - // then - assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); - } - - @Test - @DisplayName("회원본인이 작성한 게시글 목록을 조회한다.") - void getPostsByWriter() throws JsonProcessingException { - // given - long postId = 1L; - Member member = MemberFixtures.FEMALE_20.get(); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(member); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(member) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) - .build(); - - PostResponse postResponse = PostResponse.of(post, member); - - given(postService.getPostsByWriter( - anyInt(), - any(PostClosingType.class), - any(PostSortType.class), - anyLong(), - any(Member.class)) - ).willReturn(List.of(postResponse)); - - // when - List result = RestAssuredMockMvc - .given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("page", 0) - .param("postClosingType", PostClosingType.PROGRESS) - .param("postSortType", PostSortType.LATEST) - .param("category", 1L) - .when().get("/posts/me") - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - // then - assertAll( - () -> assertThat(result).hasSize(1), - () -> assertThat(result.get(0)).usingRecursiveComparison().isEqualTo(postResponse) - ); - } - - @Test - @DisplayName("(회원) 키워드를 통해 게시글 목록을 조회한다.") - void searchPostsWithKeyword() throws JsonProcessingException { - // given - long postId = 1L; - Member member = MemberFixtures.FEMALE_20.get(); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(member); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) - .build(); - - PostResponse postResponse = PostResponse.of(post, member); - - given(postService.searchPostsWithKeyword(anyString(), anyInt(), any(), any(), anyLong(), any(Member.class))) - .willReturn(List.of(postResponse)); - - // when - List result = RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .param("keyword", "하이") - .param("page", 0) - .param("postClosingType", PostClosingType.PROGRESS) - .param("postSortType", PostSortType.LATEST) - .param("category", 1L) - .when().get("/posts/search") - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - // then - assertThat(result.get(0)).usingRecursiveComparison().isEqualTo(postResponse); - } - - @Test - @DisplayName("(비회원) 키워드를 통해 게시글 목록을 조회한다.") - void searchPostsWithKeywordForGuest() { - // given - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(MALE_30.get()) - .postBody(postBody) - .deadline(LocalDateTime.now().plusDays(3L).truncatedTo(ChronoUnit.MINUTES)) - .build(); - - PostResponse postResponse = PostResponse.forGuest(post); - - given(postService.searchPostsWithKeywordForGuest( - anyString(), - anyInt(), - any(PostClosingType.class), - any(PostSortType.class), - anyLong()) - ).willReturn(List.of(postResponse)); - - // when - List result = RestAssuredMockMvc.given().log().all() - .param("keyword", "하이") - .param("page", 0) - .param("postClosingType", PostClosingType.PROGRESS) - .param("postSortType", PostSortType.LATEST) - .param("category", 1L) - .when().get("/posts/search/guest") - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - // then - assertThat(result.get(0)).usingRecursiveComparison().isEqualTo(postResponse); - } - - @Test - @DisplayName("게시글을 삭제한다.") - void delete() { - // given - long postId = 1L; - - // when, then - RestAssuredMockMvc.given().log().all() - .when().delete("/posts/{postId}", postId) - .then().log().all() - .assertThat() - .status(HttpStatus.NO_CONTENT); - } - - @Test - @DisplayName("게시글을 수정한다.") - void update() throws IOException { - // given - PostOptionUpdateRequest postOptionUpdateRequest1 = PostOptionUpdateRequest.builder() - .content("optionContent1") - .build(); - - PostOptionUpdateRequest postOptionUpdateRequest2 = PostOptionUpdateRequest.builder() - .content("optionContent2") - .build(); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder() - .categoryIds(List.of(0L)) - .title("title") - .content("content") - .deadline(LocalDateTime.now().plusDays(2)) - .postOptions(List.of(postOptionUpdateRequest1, postOptionUpdateRequest2)) - .build(); - - String fileName1 = "testImage1.PNG"; - String resultFileName1 = "testResultImage1.PNG"; - String filePath1 = "src/test/resources/images/" + fileName1; - File file1 = new File(filePath1); - - String fileName2 = "testImage2.PNG"; - String resultFileName2 = "testResultImage2.PNG"; - String filePath2 = "src/test/resources/images/" + fileName2; - File file2 = new File(filePath2); - - String fileName3 = "testImage3.PNG"; - String resultFileName3 = "testResultImage3.PNG"; - String filePath3 = "src/test/resources/images/" + fileName3; - File file3 = new File(filePath3); - - String postRequestJson = mapper.writeValueAsString(postUpdateRequest); - - TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); - given(tokenProcessor.resolveToken(anyString())).willReturn("token"); - given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); - given(memberService.findById(anyLong())).willReturn(MemberFixtures.FEMALE_20.get()); - - // when, then - RestAssuredMockMvc.given().log().all() - .headers(HttpHeaders.AUTHORIZATION, "Bearer token") - .contentType(ContentType.MULTIPART) - .multiPart("request", postRequestJson, "application/json") - .multiPart("contentImages", resultFileName3, new FileInputStream(file3), "image/png") - .multiPart("optionImages", resultFileName1, new FileInputStream(file1), "image/png") - .multiPart("optionImages", resultFileName2, new FileInputStream(file2), "image/png") - .when().put("/posts/1", 1) - .then().log().all() - .status(HttpStatus.OK); - } - - @Test - @DisplayName("인기 게시물 랭킹을 불러온다.") - void getRanking() { - // given - Post post = Post.builder() - .postBody(PostBody.builder().title("제목").build()) - .writer(MemberFixtures.MALE_10.get()) - .build(); - ReflectionTestUtils.setField(post, "id", 1L); - - PostRankingResponse postRankingResponse = new PostRankingResponse(1, PostSummaryResponse.from(post)); - given(postService.getRanking()).willReturn(List.of(postRankingResponse)); - - // when, then - List result = RestAssuredMockMvc.given().log().all() - .when().get("/posts/ranking/popular/guest") - .then().log().all() - .status(HttpStatus.OK) - .extract() - .as(new ParameterizedTypeReference>() { - }.getType()); - - assertThat(result).isEqualTo(List.of(postRankingResponse)); - } - -} diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostGuestControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostGuestControllerTest.java new file mode 100644 index 000000000..f305a9a5a --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/controller/PostGuestControllerTest.java @@ -0,0 +1,251 @@ +package com.votogether.domain.post.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import com.votogether.domain.post.dto.response.post.CategoryResponse; +import com.votogether.domain.post.dto.response.post.PostOptionVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostRankingResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.post.PostSummaryResponse; +import com.votogether.domain.post.dto.response.post.PostVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostWriterResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostGuestService; +import com.votogether.global.exception.GlobalExceptionHandler; +import com.votogether.test.ControllerTest; +import com.votogether.test.fixtures.MemberFixtures; +import io.restassured.common.mapper.TypeRef; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@WebMvcTest(PostGuestController.class) +class PostGuestControllerTest extends ControllerTest { + + @MockBean + PostGuestService postGuestService; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + RestAssuredMockMvc.standaloneSetup( + MockMvcBuilders + .standaloneSetup(new PostGuestController(postGuestService)) + .setControllerAdvice(GlobalExceptionHandler.class) + ); + RestAssuredMockMvc.webAppContextSetup(webApplicationContext); + } + + @Nested + @DisplayName("비회원 게시글 목록 조회") + class GuestGetPosts { + + @Test + @DisplayName("게시글 목록 조회에 성공하면 200 응답을 반환한다.") + void return200success() { + // given + List response = List.of(mockingPostResponse()); + given(postGuestService.getPosts(anyInt(), any(PostClosingType.class), any(PostSortType.class), any())) + .willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/guest") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음의 정수라면 400 응답을 반환한다.") + void return400pageIsNegative() { + // given, when, then + RestAssuredMockMvc.given().log().all() + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/guest") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("비회원 게시글 상세 조회") + class GuestGetPost { + + @Test + @DisplayName("게시글 상세 조회에 성공하면 200 응답을 반환한다.") + void return200success() { + // given + PostResponse response = mockingPostResponse(); + given(postGuestService.getPost(anyLong())).willReturn(response); + + // when + PostResponse result = RestAssuredMockMvc.given().log().all() + .when().get("/posts/{postId}/guest", 1L) + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @ParameterizedTest + @ValueSource(longs = {-1, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdIsNegativeOrZero(Long postId) { + // given, when, then + RestAssuredMockMvc.given().log().all() + .when().get("/posts/{postId}/guest", postId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("비회원 게시글 목록 검색") + class GuestSearchPosts { + + @Test + @DisplayName("게시글 목록 검색에 성공하면 200 응답을 반환한다.") + void return200success() { + // given + List response = List.of(mockingPostResponse()); + given(postGuestService.searchPosts( + anyString(), + anyInt(), + any(PostClosingType.class), + any(PostSortType.class) + )).willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .param("keyword", "votogether") + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/search/guest") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음의 정수라면 400 응답을 반환한다.") + void return400pageIsNegative() { + // given, when, then + RestAssuredMockMvc.given().log().all() + .param("keyword", "votogether") + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/search/guest") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Test + @DisplayName("인기 게시물 랭킹을 불러온다.") + void getRanking() { + // given + Post post = Post.builder() + .title("제목") + .content("내용") + .writer(MemberFixtures.MALE_10.get()) + .deadline(LocalDateTime.now().plusDays(3)) + .build(); + ReflectionTestUtils.setField(post, "id", 1L); + PostRankingResponse postRankingResponse = new PostRankingResponse(1, PostSummaryResponse.from(post)); + given(postGuestService.getRanking()).willReturn(List.of(postRankingResponse)); + + // when, then + List result = RestAssuredMockMvc.given().log().all() + .when().get("/posts/ranking/popular/guest") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef>() { + }.getType()); + + assertThat(result).isEqualTo(List.of(postRankingResponse)); + } + + private PostResponse mockingPostResponse() { + return new PostResponse( + 1L, + new PostWriterResponse(1L, "votogether"), + "title", + "content", + "https://votogether.com/static/images/image.png", + List.of( + new CategoryResponse(1L, "develop") + ), + LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), + LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), + 3, + 15, + new PostVoteResultResponse( + 0L, + -1, + List.of( + new PostOptionVoteResultResponse( + 1L, + "content", + "image.png", + -1, + 0 + ) + ) + ) + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/controller/PostQueryControllerTest.java b/backend/src/test/java/com/votogether/domain/post/controller/PostQueryControllerTest.java new file mode 100644 index 000000000..41bda7987 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/controller/PostQueryControllerTest.java @@ -0,0 +1,488 @@ +package com.votogether.domain.post.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.CategoryResponse; +import com.votogether.domain.post.dto.response.post.PostOptionVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.post.PostVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostWriterResponse; +import com.votogether.domain.post.dto.response.vote.VoteCountForAgeGroupResponse; +import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostQueryService; +import com.votogether.global.exception.GlobalExceptionHandler; +import com.votogether.test.ControllerTest; +import io.restassured.common.mapper.TypeRef; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@WebMvcTest(PostQueryController.class) +class PostQueryControllerTest extends ControllerTest { + + @MockBean + PostQueryService postQueryService; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + RestAssuredMockMvc.standaloneSetup( + MockMvcBuilders + .standaloneSetup(new PostQueryController(postQueryService)) + .setControllerAdvice(GlobalExceptionHandler.class) + ); + RestAssuredMockMvc.webAppContextSetup(webApplicationContext); + } + + @Nested + @DisplayName("게시글 목록 조회") + class GetPosts { + + @Test + @DisplayName("게시글 목록 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + List response = List.of(mockingPostResponse()); + given(postQueryService.getPosts( + anyInt(), + any(PostClosingType.class), + any(PostSortType.class), + any(), + any(Member.class)) + ).willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음수라면 400 응답을 반환한다.") + void return400pageNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("게시글 상세 조회") + class GetPost { + + @Test + @DisplayName("게시글 상세 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + PostResponse response = mockingPostResponse(); + given(postQueryService.getPost(anyLong(), any(Member.class))).willReturn(response); + + // when + PostResponse result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}", 1) + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(PostResponse.class); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @ParameterizedTest + @ValueSource(longs = {-1, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative(Long postId) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}", postId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("게시글 검색") + class SearchPosts { + + @Test + @DisplayName("게시글 검색에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + List response = List.of(mockingPostResponse()); + given(postQueryService.searchPosts( + anyString(), + anyInt(), + any(PostClosingType.class), + any(PostSortType.class), + any(Member.class) + )).willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("keyword", "hello") + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/search") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음수라면 400 응답을 반환한다.") + void return400pageNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("keyword", "hello") + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/search") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("내가 쓴 게시글 조회") + class GetPostsWrittenByMe { + + @Test + @DisplayName("내가 쓴 게시글 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + List response = List.of(mockingPostResponse()); + given(postQueryService.getPostsWrittenByMe( + anyInt(), + any(PostClosingType.class), + any(PostSortType.class), + any(Member.class) + )).willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/me") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음수라면 400 응답을 반환한다.") + void return400pageNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/me") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("내가 투표한 게시글 조회") + class GetPostsVotedByMe { + + @Test + @DisplayName("내가 투표한 게시글 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + List response = List.of(mockingPostResponse()); + given(postQueryService.getPostsVotedByMe( + anyInt(), + any(PostClosingType.class), + any(PostSortType.class), + any(Member.class) + )).willReturn(response); + + // when + List result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", 0) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/votes/me") + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @Test + @DisplayName("페이지가 음수라면 400 응답을 반환한다.") + void return400pageNegative() throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .param("page", -1) + .param("postClosingType", PostClosingType.ALL) + .param("postSortType", PostSortType.LATEST) + .when().get("/posts/votes/me") + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("페이지는 0이상 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("게시글 투표 통계 조회") + class GetPostVoteStatistics { + + @Test + @DisplayName("게시글 투표 통계 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + VoteOptionStatisticsResponse response = mockingVoteOptionStatisticsResponse(); + given(postQueryService.getVoteStatistics(anyLong(), any(Member.class))).willReturn(response); + + // when + VoteOptionStatisticsResponse result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}/options", 1L) + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(VoteOptionStatisticsResponse.class); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @ParameterizedTest + @ValueSource(longs = {-1, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative(Long postId) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}/options", postId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + } + + @Nested + @DisplayName("게시글 투표 옵션 통계 조회") + class GetPostOptionVoteStatistics { + + @Test + @DisplayName("게시글 투표 옵션 통계 조회에 성공하면 200 응답을 반환한다.") + void return200success() throws Exception { + // given + mockingAuthArgumentResolver(); + VoteOptionStatisticsResponse response = mockingVoteOptionStatisticsResponse(); + given(postQueryService.getVoteOptionStatistics( + anyLong(), + anyLong(), + any(Member.class) + )).willReturn(response); + + // when + VoteOptionStatisticsResponse result = RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}/options/{optionId}", 1L, 1L) + .then().log().all() + .status(HttpStatus.OK) + .extract() + .as(VoteOptionStatisticsResponse.class); + + // then + assertThat(result).usingRecursiveComparison().isEqualTo(response); + } + + @ParameterizedTest + @ValueSource(longs = {-1, 0}) + @DisplayName("게시글 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postIdNegative(Long postId) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}/options/{optionId}", postId, 1L) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 ID는 양의 정수만 가능합니다.")); + } + + @ParameterizedTest + @ValueSource(longs = {-1, 0}) + @DisplayName("게시글 투표 옵션 ID가 양의 정수가 아니라면 400 응답을 반환한다.") + void return400postOptionIdNegative(Long postOptionId) throws Exception { + // given + mockingAuthArgumentResolver(); + + // when, then + RestAssuredMockMvc.given().log().all() + .headers(HttpHeaders.AUTHORIZATION, "Bearer token") + .when().get("/posts/{postId}/options/{optionId}", 1L, postOptionId) + .then().log().all() + .status(HttpStatus.BAD_REQUEST) + .body("code", equalTo(201)) + .body("message", containsString("게시글 옵션 ID는 양의 정수만 가능합니다.")); + } + + } + + private PostResponse mockingPostResponse() { + return new PostResponse( + 1L, + new PostWriterResponse(1L, "votogether"), + "title", + "content", + "https://votogether.com/static/images/image.png", + List.of( + new CategoryResponse(1L, "develop") + ), + LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), + LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES), + 3, + 15, + new PostVoteResultResponse( + 0L, + -1, + List.of( + new PostOptionVoteResultResponse( + 1L, + "content", + "image.png", + -1, + 0 + ) + ) + ) + ); + } + + private VoteOptionStatisticsResponse mockingVoteOptionStatisticsResponse() { + return new VoteOptionStatisticsResponse( + 10, + 5, + 5, + List.of( + new VoteCountForAgeGroupResponse( + "10대", + 2, + 1, + 1 + ), + new VoteCountForAgeGroupResponse( + "20대", + 3, + 1, + 2 + ) + ) + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostBodyTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostBodyTest.java new file mode 100644 index 000000000..49b3a4bb1 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostBodyTest.java @@ -0,0 +1,90 @@ +package com.votogether.domain.post.entity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.votogether.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +class PostBodyTest { + + @Nested + @DisplayName("게시글 바디 생성") + class PostBodyCreate { + + @Test + @DisplayName("게시글 제목과 내용이 정상적이라면 게시글 바디를 생성한다.") + void success() { + // given + String title = "votogether"; + String content = "안녕 모두들 hello world!"; + + // when + PostBody postBody = new PostBody(title, content); + + // then + assertSoftly(softly -> { + softly.assertThat(postBody.getTitle()).isEqualTo(title); + softly.assertThat(postBody.getContent()).isEqualTo(content); + }); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("게시글 제목이 존재하지 않거나 공백이라면 예외를 던진다.") + void emptyTitle(String title) { + // given + String content = "안녕 모두들 hello world!"; + + // when, then + assertThatThrownBy(() -> new PostBody(title, content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 제목은 비어있거나 공백일 수 없습니다."); + } + + @Test + @DisplayName("게시글 제목 길이가 유효하지 않으면 예외를 던진다.") + void invalidTitleLength() { + // given + String title = "a".repeat(101); + String content = "안녕 모두들 hello world!"; + + // when, then + assertThatThrownBy(() -> new PostBody(title, content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 제목 길이가 유효하지 않습니다."); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("게시글 내용이 존재하지 않거나 공백이라면 예외를 던진다.") + void emptyContent(String content) { + // given + String title = "votogether"; + + // when, then + assertThatThrownBy(() -> new PostBody(title, content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 내용은 비어있거나 공백일 수 없습니다."); + } + + @Test + @DisplayName("게시글 내용 길이가 유효하지 않으면 예외를 던진다.") + void invalidContentLength() { + // given + String title = "votogether"; + String content = "a".repeat(1001); + + // when, then + assertThatThrownBy(() -> new PostBody(title, content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 내용 길이가 유효하지 않습니다."); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostCategoriesTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostCategoriesTest.java deleted file mode 100644 index 271ebf660..000000000 --- a/backend/src/test/java/com/votogether/domain/post/entity/PostCategoriesTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.votogether.domain.post.entity; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.votogether.domain.category.entity.Category; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class PostCategoriesTest { - - @Test - @DisplayName("여러 Category를 전달하면 Post와 매핑되어 PostOptions를 생성한다") - void mapPostAndCategories() { - // given - PostCategories postCategories = new PostCategories(); - Post post = Post.builder().build(); - Category categoryA = Category.builder().build(); - Category categoryB = Category.builder().build(); - - List categories = List.of(categoryA, categoryB); - - // when - postCategories.mapPostAndCategories(post, categories); - - // then - assertThat(postCategories.getPostCategories()).hasSize(2); - } - -} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostDeadlineTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostDeadlineTest.java new file mode 100644 index 000000000..60eb50d54 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostDeadlineTest.java @@ -0,0 +1,77 @@ +package com.votogether.domain.post.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.votogether.global.exception.BadRequestException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PostDeadlineTest { + + @Nested + @DisplayName("게시글 마감시간 생성") + class PostDeadlineCreate { + + @Test + @DisplayName("게시글 마감시간이 정상적이라면 게시글 마감시간을 생성한다.") + void success() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(14); + + // when + PostDeadline postDeadline = new PostDeadline(deadline); + + // then + assertThat(postDeadline.getDeadline()).isEqualTo(deadline); + } + + @Test + @DisplayName("게시글 마감시간이 최대 마감시간을 초과하면 예외를 던진다.") + void invalidDeadline() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(15); + + // when, then + assertThatThrownBy(() -> new PostDeadline(deadline)) + .isInstanceOf(BadRequestException.class) + .hasMessage("최대 마감기간을 초과하였습니다."); + } + + } + + @Nested + @DisplayName("게시글 마감 여부 확인") + class IsClosed { + + @Test + @DisplayName("게시글이 마감되었으면 true 반환한다.") + void isClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + + // when + PostDeadline postDeadline = new PostDeadline(deadline); + + // then + assertThat(postDeadline.isClosed()).isTrue(); + } + + @Test + @DisplayName("게시글이 마감되지 않았으면 false 반환한다.") + void isOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + + // when + PostDeadline postDeadline = new PostDeadline(deadline); + + // then + assertThat(postDeadline.isClosed()).isFalse(); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostOptionBodyTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostOptionBodyTest.java new file mode 100644 index 000000000..895ba0136 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostOptionBodyTest.java @@ -0,0 +1,56 @@ +package com.votogether.domain.post.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.votogether.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +class PostOptionBodyTest { + + @Nested + @DisplayName("게시글 옵션 본문 생성") + class PostOptionBodyCreate { + + @Test + @DisplayName("게시글 옵션 본문이 정상적이라면 게시글 옵션 본문을 생성한다.") + void success() { + // given + String content = "votogether"; + + // when + PostOptionBody postOptionBody = new PostOptionBody(content); + + // then + assertThat(postOptionBody.getContent()).isEqualTo(content); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("게시글 옵션 본문이 존재하지 않거나 공백이라면 예외를 던진다.") + void emptyContent(String content) { + // given, when, then + assertThatThrownBy(() -> new PostOptionBody(content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 옵션 내용은 비어있거나 공백일 수 없습니다."); + } + + @Test + @DisplayName("게시글 옵션 본문 길이가 유효하지 않으면 예외를 던진다.") + void invalidContentLength() { + // given + String content = "a".repeat(51); + + // when, then + assertThatThrownBy(() -> new PostOptionBody(content)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 옵션 내용 길이가 유효하지 않습니다."); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostOptionTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostOptionTest.java index 0eb9d854c..a1563393c 100644 --- a/backend/src/test/java/com/votogether/domain/post/entity/PostOptionTest.java +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostOptionTest.java @@ -1,28 +1,93 @@ package com.votogether.domain.post.entity; -import static com.votogether.test.fixtures.MemberFixtures.MALE_30; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; -import com.votogether.domain.vote.entity.Vote; +import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; class PostOptionTest { @Test - @DisplayName("해당 선택지를 현재 회원이 투표한 건지 확인한다") - void isVoteByMember() { + @DisplayName("게시글 옵션을 수정한다.") + void update() { // given - PostOption postOption = PostOption.builder().build(); - - Vote vote = Vote.builder().member(MALE_30.get()).build(); - postOption.getVotes().add(vote); + PostOption postOption = PostOption.builder() + .content("hello") + .imageUrl(null) + .build(); + String newContent = "votogether"; + String newImageUrl = "image.png"; // when - boolean isVoteByMember = postOption.hasMemberVote(MALE_30.get()); + postOption.update(newContent, newImageUrl); // then - assertThat(isVoteByMember).isTrue(); + assertSoftly(softly -> { + softly.assertThat(postOption.getContent()).isEqualTo(newContent); + softly.assertThat(postOption.getImageUrl()).isEqualTo(newImageUrl); + }); + } + + @Nested + @DisplayName("속한 게시글 확인") + class BelongsToPost { + + @Test + @DisplayName("게시글에 속해있으면 true 반환한다.") + void belongsTo() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + PostOption postOption = PostOption.builder() + .post(post) + .content("hello") + .imageUrl(null) + .build(); + ReflectionTestUtils.setField(post, "id", 1L); + + // when + boolean result = postOption.belongsTo(post); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("게시글에 속해있지 않으면 false 반환한다") + void notBelongsTo() { + // given + Post postA = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + Post postB = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + PostOption postOption = PostOption.builder() + .post(postB) + .content("hello") + .imageUrl(null) + .build(); + ReflectionTestUtils.setField(postA, "id", 1L); + ReflectionTestUtils.setField(postB, "id", 2L); + + // when + boolean result = postOption.belongsTo(postA); + + // then + assertThat(result).isFalse(); + } + } } diff --git a/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java index 9b04243ed..ee5f7c669 100644 --- a/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java +++ b/backend/src/test/java/com/votogether/domain/post/entity/PostTest.java @@ -1,205 +1,244 @@ package com.votogether.domain.post.entity; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; +import static org.assertj.core.api.SoftAssertions.assertSoftly; -import com.votogether.domain.category.entity.Category; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.global.exception.BadRequestException; -import com.votogether.global.util.ImageUploader; import com.votogether.test.fixtures.MemberFixtures; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; class PostTest { @Test - @DisplayName("여러 Category를 전달하면 Post와 매핑되어 PostOptions를 생성한다") - void mapCategories() { + @DisplayName("게시글 옵션을 삭제한다.") + void removePostOption() { // given - Post post = Post.builder().build(); - Category categoryA = Category.builder().build(); - Category categoryB = Category.builder().build(); - - List categories = List.of(categoryA, categoryB); + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + PostOption postOption = PostOption.builder() + .content("hello") + .imageUrl(null) + .build(); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "id", 1L); // when - post.mapCategories(categories); + post.removePostOption(postOption); // then - PostCategories actualPostCategories = post.getPostCategories(); - assertThat(actualPostCategories.getPostCategories()).hasSize(2); + assertThat(post.getPostOptions()).isEmpty(); } @Test - @DisplayName("PostOption의 내용을 전달하면 Post와 PostOption이 매핑된다") - void mapPostOptionsByElements() { + @DisplayName("게시글을 수정한다.") + void update() { // given - Post post = Post.builder().build(); - - byte[] content = "Hello, World!".getBytes(StandardCharsets.UTF_8); - MockMultipartFile file1 = new MockMultipartFile( - "file1", - "hello1.txt", - MediaType.TEXT_PLAIN_VALUE, - content - ); - - MockMultipartFile file2 = new MockMultipartFile( - "file2", - "hello2.txt", - MediaType.TEXT_PLAIN_VALUE, - content - ); - - final List imageUrls = Stream.of(file1, file2) - .map(ImageUploader::upload) - .toList(); + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + String newTitle = "hello world"; + String newContent = "votogether"; + LocalDateTime newDeadline = LocalDateTime.now().plusDays(1); // when - post.mapPostOptionsByElements(List.of("content1", "content2"), imageUrls); + post.update(newTitle, newContent, newDeadline); // then - List postOptions = post.getPostOptions().getPostOptions(); - assertThat(postOptions).hasSize(2); + assertSoftly(softly -> { + softly.assertThat(post.getTitle()).isEqualTo(newTitle); + softly.assertThat(post.getContent()).isEqualTo(newContent); + softly.assertThat(post.getDeadline()).isEqualTo(newDeadline); + }); } - @Test - @DisplayName("게시글 작성 시, 게시글의 마감 기한이 현재 시간보다 3일 초과 여부에 따라 예외를 던질 지 결정한다.") - void throwExceptionIsWriter() { - // given - final Member writer = MemberFixtures.MALE_30.get(); - ReflectionTestUtils.setField(writer, "id", 1L); - - Post post1 = Post.builder() - .writer(writer) - .deadline(LocalDateTime.now().plusDays(4)) - .build(); + @Nested + @DisplayName("삭제 가능 여부 확인") + class CanDelete { + + @Test + @DisplayName("투표 수가 삭제 제한 개수보다 작으면 true 반환한다.") + void canDelete() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + PostOption postOption = PostOption.builder() + .content("hello") + .imageUrl(null) + .build(); + ReflectionTestUtils.setField(postOption, "voteCount", 19); + post.addPostOption(postOption); + + // when + boolean result = post.canDelete(); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("투표 수가 삭제 제한 개수보다 많으면 false 반환한다.") + void cannotDelete() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + PostOption postOption = PostOption.builder() + .content("hello") + .imageUrl(null) + .build(); + ReflectionTestUtils.setField(postOption, "voteCount", 20); + post.addPostOption(postOption); + + // when + boolean result = post.canDelete(); + + // then + assertThat(result).isFalse(); + } - Post post2 = Post.builder() - .writer(writer) - .deadline(LocalDateTime.now().plusDays(2)) - .build(); - - // when, then - assertAll( - () -> assertThatThrownBy(() -> post1.validateDeadlineNotExceedByMaximumDeadline(3)) - .isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS.getMessage()), - () -> assertThatNoException() - .isThrownBy(() -> post2.validateDeadlineNotExceedByMaximumDeadline(3)) - ); } - @Test - @DisplayName("게시글의 마감 여부에 따라 예외를 던질 지 결정한다.") - void throwExceptionIsDeadlinePassed() { - // given - final Member writer = MemberFixtures.MALE_30.get(); - ReflectionTestUtils.setField(writer, "id", 1L); - - Post post1 = Post.builder() - .writer(writer) - .deadline(LocalDateTime.of(2000, 1, 1, 1, 1)) - .build(); - - Post post2 = Post.builder() - .writer(writer) - .deadline(LocalDateTime.of(9999, 1, 1, 1, 1)) - .build(); + @Nested + @DisplayName("작성자 확인") + class IsWriter { + + @Test + @DisplayName("게시글 작성자라면 true 반환한다.") + void isWriter() { + // given + Member member = MemberFixtures.MALE_20.get(); + Post post = Post.builder() + .writer(member) + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(member, "id", 1L); + + // when + boolean result = post.isWriter(member); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 false 반환한다.") + void isNotWriter() { + // given + Member writer = MemberFixtures.MALE_20.get(); + Member member = MemberFixtures.MALE_30.get(); + Post post = Post.builder() + .writer(writer) + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(writer, "id", 1L); + ReflectionTestUtils.setField(member, "id", 2L); + + // when + boolean result = post.isWriter(member); + + // then + assertThat(result).isFalse(); + } - // when, then - assertAll( - () -> assertThatThrownBy(post1::validateDeadLine) - .isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.POST_CLOSED.getMessage()), - () -> assertThatNoException() - .isThrownBy(post2::validateDeadLine) - ); } - @Test - @DisplayName("해당 게시글을 조기 마감 합니다.") - void closedEarly() { - // given - LocalDateTime deadline = LocalDateTime.of(2100, 1, 1, 0, 0); - Post post = Post.builder() - .deadline(deadline) - .build(); - - // when - post.closeEarly(); + @Nested + @DisplayName("게시글 블라인드") + class Blind { + + @Test + @DisplayName("게시글 생성 시 블라인드 되어 있지 않다.") + void initNotBlind() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + + // when, then + assertThat(post.isHidden()).isFalse(); + } + + @Test + @DisplayName("게시글을 블라인드한다.") + void blind() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + + // when + post.blind(); + + // then + assertThat(post.isHidden()).isTrue(); + } - // then - assertThat(post.getDeadline()).isBefore(deadline); } - @Test - @DisplayName("게시글을 수정한다.") - void update() { - // given - final Member writer = MemberFixtures.MALE_30.get(); - final PostBody postBody1 = PostBody.builder() - .title("title1") - .content("content1") - .build(); - final Post post = Post.builder() - .writer(writer) - .postBody(postBody1) - .deadline(LocalDateTime.now().plusDays(3)) - .build(); - post.addContentImage("없는사진"); - - final PostBody postBody2 = PostBody.builder() - .title("title2") - .content("content2") - .build(); - - Category categoryA = Category.builder().name("category1").build(); - Category categoryB = Category.builder().name("category2").build(); - - List categories = List.of(categoryA, categoryB); + @Nested + @DisplayName("게시글 첫번째 이미지 조회") + class GetFirstImage { + + @Test + @DisplayName("게시글 이미지가 존재하지 않으면 null 반환한다.") + void returnNull() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + + // when + PostContentImage result = post.getFirstContentImage(); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("게시글 이미지가 존재하면 첫번째 이미지를 반환한다.") + void returnFirstImage() { + // given + Post post = Post.builder() + .title("hello") + .content("world") + .deadline(LocalDateTime.now()) + .build(); + post.addContentImage("image.png"); + + // when + PostContentImage result = post.getFirstContentImage(); + + // then + assertSoftly(softly -> { + softly.assertThat(result).isNotNull(); + softly.assertThat(result.getImageUrl()).isEqualTo("image.png"); + }); + } - // when - final LocalDateTime deadline = LocalDateTime.now().plusDays(2); - post.update( - postBody2, - "oldContentUrl", - List.of("newContentUrl2"), - categories, - List.of("option1", "option2"), - List.of("optionImage1", "optionImage2"), - List.of("newOptionImage1", "newOptionImage2"), - deadline - ); - - // then - final PostBody postBody = post.getPostBody(); - final PostContentImage postContentImage = postBody.getPostContentImages().getContentImages().get(0); - final List postOptions = post.getPostOptions().getPostOptions(); - final List postCategories = post.getPostCategories().getPostCategories(); - final LocalDateTime actualDeadline = post.getDeadline(); - assertAll( - () -> assertThat(postBody.getTitle()).isEqualTo("title2"), - () -> assertThat(postBody.getContent()).isEqualTo("content2"), - () -> assertThat(postContentImage.getImageUrl()).isEqualTo("newContentUrl2"), - () -> assertThat(postOptions.get(0).getContent()).isEqualTo("option1"), - () -> assertThat(postOptions.get(0).getImageUrl()).isEqualTo("newOptionImage1"), - () -> assertThat(postCategories.get(0).getCategory().getName()).isEqualTo("category1"), - () -> assertThat(actualDeadline).hasYear(deadline.getYear()), - () -> assertThat(actualDeadline).hasMonth(deadline.getMonth()), - () -> assertThat(actualDeadline).hasDayOfMonth(deadline.getDayOfMonth()) - ); } } diff --git a/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java b/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java index 8d3fc2e5e..33d83019c 100644 --- a/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java +++ b/backend/src/test/java/com/votogether/domain/post/entity/comment/CommentTest.java @@ -1,38 +1,40 @@ package com.votogether.domain.post.entity.comment; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.global.exception.BadRequestException; import com.votogether.test.fixtures.MemberFixtures; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; class CommentTest { + private Post generatePost() { + return Post.builder() + .writer(MemberFixtures.FEMALE_20.get()) + .title("title") + .content("content") + .deadline(LocalDateTime.now()) + .build(); + } + @Test @DisplayName("댓글 내용이 최대 글자를 초과하면 예외를 던진다.") void invalidContentLength() { // given String content = "a".repeat(501); - PostBody body = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() - .writer(MemberFixtures.FEMALE_20.get()) - .postBody(body) - .deadline(LocalDateTime.now()) - .build(); + Post post = generatePost(); // when, then assertThatThrownBy( () -> Comment.builder() - .member(MemberFixtures.MALE_20.get()) + .writer(MemberFixtures.MALE_20.get()) .post(post) .content(content) .build() @@ -41,94 +43,144 @@ void invalidContentLength() { .hasMessage("유효하지 않은 댓글 길이입니다."); } - @Test - @DisplayName("댓글 작성자가 아니라면 예외를 던진다.") - void invalidWriter() { - // given - Member member = MemberFixtures.FEMALE_20.get(); - PostBody body = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() - .writer(member) - .postBody(body) - .deadline(LocalDateTime.now()) - .build(); - Comment comment = Comment.builder() - .member(member) - .post(post) - .content("content") - .build(); + @Nested + @DisplayName("게시글 댓글 확인") + class PostBelongCheck { - ReflectionTestUtils.setField(member, "id", 1L); + @Test + @DisplayName("게시글의 댓글이라면 true 반환한다.") + void belongsToPost() { + // given + Post post = generatePost(); + Comment comment = Comment.builder() + .writer(MemberFixtures.MALE_20.get()) + .post(post) + .content("hello") + .build(); + + // when, then + assertThat(comment.belongsTo(post)).isTrue(); + } + + @Test + @DisplayName("게시글의 댓글이 아니라면 false 반환한다.") + void notBelongToPost() { + // given + Post post = generatePost(); + Post otherPost = generatePost(); + Comment comment = Comment.builder() + .writer(MemberFixtures.MALE_20.get()) + .post(post) + .content("hello") + .build(); + ReflectionTestUtils.setField(post, "id", 1L); + + // when, then + assertThat(comment.belongsTo(otherPost)).isFalse(); + } - // when, then - assertThatThrownBy(() -> comment.validateWriter(MemberFixtures.MALE_20.get())) - .isInstanceOf(BadRequestException.class) - .hasMessage("댓글 작성자가 아닙니다."); } - @Test - @DisplayName("작성되어 있는 게시글이 아니라면 예외를 던진다.") - void invalidPost() { - // given - Member member = MemberFixtures.FEMALE_20.get(); - PostBody bodyA = PostBody.builder() - .title("title") - .content("content") - .build(); - Post postA = Post.builder() - .writer(member) - .postBody(bodyA) - .deadline(LocalDateTime.now()) - .build(); - PostBody bodyB = PostBody.builder() - .title("title") - .content("content") - .build(); - Post postB = Post.builder() - .writer(member) - .postBody(bodyB) - .deadline(LocalDateTime.now()) - .build(); - Comment comment = Comment.builder() - .member(member) - .post(postA) - .content("content") - .build(); + @Nested + @DisplayName("댓글 작성자 확인") + class WriterCheck { - ReflectionTestUtils.setField(postA, "id", 1L); + @Test + @DisplayName("작성자라면 true 반환한다.") + void isWriter() { + // given + Member male20Member = MemberFixtures.MALE_20.get(); + Post post = generatePost(); + Comment comment = Comment.builder() + .writer(male20Member) + .post(post) + .content("hello") + .build(); + ReflectionTestUtils.setField(male20Member, "id", 1L); + + // when, then + assertThat(comment.isWriter(male20Member)).isTrue(); + } + + @Test + @DisplayName("작성자가 아니라면 false 반환한다.") + void isNotWriter() { + // given + Member male20Member = MemberFixtures.MALE_20.get(); + Member female20Member = MemberFixtures.FEMALE_20.get(); + Post post = generatePost(); + Comment comment = Comment.builder() + .writer(male20Member) + .post(post) + .content("hello") + .build(); + ReflectionTestUtils.setField(male20Member, "id", 1L); + + // when, then + assertThat(comment.isWriter(female20Member)).isFalse(); + } + + } + + @Nested + @DisplayName("댓글 내용 수정") + class UpdateContent { + + @Test + @DisplayName("유효한 댓글이라면 댓글 내용이 수정된다.") + void updateContent() { + // given + Post post = generatePost(); + Comment comment = Comment.builder() + .writer(MemberFixtures.MALE_20.get()) + .post(post) + .content("hello") + .build(); + String newContent = "votogether"; + + // when + comment.updateContent(newContent); + + // then + assertThat(comment.getContent()).isEqualTo("votogether"); + } + + @Test + @DisplayName("유효하지 않은 댓글이라면 예외를 던진다.") + void updateContentFail() { + // given + Post post = generatePost(); + Comment comment = Comment.builder() + .writer(MemberFixtures.MALE_20.get()) + .post(post) + .content("hello") + .build(); + String newContent = "a".repeat(501); + + // when, then + assertThatThrownBy(() -> comment.updateContent(newContent)) + .isInstanceOf(BadRequestException.class) + .hasMessage("유효하지 않은 댓글 길이입니다."); + } - // when, then - assertThatThrownBy(() -> comment.validateBelong(postB)) - .isInstanceOf(BadRequestException.class) - .hasMessage("댓글의 게시글 정보와 일치하지 않습니다."); } @Test - @DisplayName("댓글 수정 시 최대 글자를 초과하면 예외를 던진다.") - void updateContentWithInvalidContentLength() { + @DisplayName("댓글을 숨긴다.") + void blind() { // given - PostBody body = PostBody.builder() - .title("title") - .content("content") - .build(); - Post post = Post.builder() - .writer(MemberFixtures.FEMALE_20.get()) - .postBody(body) - .deadline(LocalDateTime.now()) - .build(); + Post post = generatePost(); Comment comment = Comment.builder() - .member(MemberFixtures.MALE_20.get()) + .writer(MemberFixtures.MALE_20.get()) .post(post) .content("hello") .build(); - // when, then - assertThatThrownBy(() -> comment.updateContent("a".repeat(501))) - .isInstanceOf(BadRequestException.class) - .hasMessage("유효하지 않은 댓글 길이입니다."); + // when + comment.blind(); + + // then + assertThat(comment.isHidden()).isTrue(); } } diff --git a/backend/src/test/java/com/votogether/domain/post/repository/CommentRepositoryTest.java b/backend/src/test/java/com/votogether/domain/post/repository/CommentRepositoryTest.java index c50d89c9d..6b27a918a 100644 --- a/backend/src/test/java/com/votogether/domain/post/repository/CommentRepositoryTest.java +++ b/backend/src/test/java/com/votogether/domain/post/repository/CommentRepositoryTest.java @@ -3,62 +3,65 @@ import static org.assertj.core.api.Assertions.assertThat; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.repository.MemberRepository; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.domain.post.entity.comment.Comment; -import com.votogether.test.annotation.RepositoryTest; -import com.votogether.test.fixtures.MemberFixtures; -import java.time.LocalDateTime; +import com.votogether.test.RepositoryTest; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -@RepositoryTest -class CommentRepositoryTest { +class CommentRepositoryTest extends RepositoryTest { @Autowired CommentRepository commentRepository; - @Autowired - MemberRepository memberRepository; - - @Autowired - PostRepository postRepository; - @Test @DisplayName("게시글의 댓글 목록을 조회한다.") void findAllByPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment commentA = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("commentA") - .build() - ); - Comment commentB = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("commentB") - .build() - ); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(writer).save(); + Comment commentA = commentTestPersister.builder().post(post).writer(writer).save(); + Comment commentB = commentTestPersister.builder().post(post).writer(writer).save(); + + // when + List comments = commentRepository.findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(post); + + // then + assertThat(comments).containsExactly(commentA, commentB); + } + + @Test + @DisplayName("작성자의 댓글 목록을 조회한다.") + void findAllByWriter() { + // given + Member writer = memberTestPersister.builder().save(); + Comment commentA = commentTestPersister.builder().writer(writer).save(); + Comment commentB = commentTestPersister.builder().writer(writer).save(); + + // when + List comments = commentRepository.findAllByWriter(writer); + + // then + assertThat(comments).containsExactly(commentA, commentB); + } + + @Test + @DisplayName("게시글의 모든 댓글을 삭제한다.") + void deleteAllWithPostIdInBatch() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + commentTestPersister.builder().writer(writer).post(post).save(); + commentTestPersister.builder().writer(writer).post(post).save(); + commentTestPersister.builder().writer(writer).post(post).save(); // when - List result = commentRepository.findAllByPostAndIsHiddenFalseOrderByCreatedAtAsc(post); + commentRepository.deleteAllWithPostIdInBatch(post.getId()); // then - assertThat(result).containsExactly(commentA, commentB); + assertThat(commentRepository.findAll()).isEmpty(); } } diff --git a/backend/src/test/java/com/votogether/domain/post/repository/PostCategoryRepositoryTest.java b/backend/src/test/java/com/votogether/domain/post/repository/PostCategoryRepositoryTest.java new file mode 100644 index 000000000..5a944b9c7 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/repository/PostCategoryRepositoryTest.java @@ -0,0 +1,48 @@ +package com.votogether.domain.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostCategory; +import com.votogether.test.RepositoryTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class PostCategoryRepositoryTest extends RepositoryTest { + + @Autowired + PostCategoryRepository postCategoryRepository; + + @Test + @DisplayName("게시글의 모든 게시글 카테고리 목록을 조회한다.") + void findAllPostCategoriesInPost() { + // given + Post post = postTestPersister.postBuilder().save(); + PostCategory postCategoryA = postTestPersister.postCategoryBuilder().post(post).save(); + PostCategory postCategoryB = postTestPersister.postCategoryBuilder().post(post).save(); + + // when + List result = postCategoryRepository.findAllByPost(post); + + // then + assertThat(result).containsExactly(postCategoryA, postCategoryB); + } + + @Test + @DisplayName("게시글의 모든 게시글 카테고리를 삭제한다.") + void deleteAllWithPostIdInBatch() { + // given + Post post = postTestPersister.postBuilder().save(); + postTestPersister.postCategoryBuilder().post(post).save(); + postTestPersister.postCategoryBuilder().post(post).save(); + + // when + postCategoryRepository.deleteAllWithPostIdInBatch(post.getId()); + + // then + assertThat(postCategoryRepository.findAll()).isEmpty(); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/repository/PostContentImageRepositoryTest.java b/backend/src/test/java/com/votogether/domain/post/repository/PostContentImageRepositoryTest.java new file mode 100644 index 000000000..43b8dccb5 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/repository/PostContentImageRepositoryTest.java @@ -0,0 +1,31 @@ +package com.votogether.domain.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.votogether.domain.post.entity.Post; +import com.votogether.test.RepositoryTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class PostContentImageRepositoryTest extends RepositoryTest { + + @Autowired + PostContentImageRepository postContentImageRepository; + + @Test + @DisplayName("게시글의 모든 게시글 이미지를 삭제한다.") + void deleteAllWithPostIdInBatch() { + // given + Post post = postTestPersister.postBuilder().save(); + postTestPersister.postContentImageBuilder().post(post).save(); + postTestPersister.postContentImageBuilder().post(post).save(); + + // when + postContentImageRepository.deleteAllWithPostIdInBatch(post.getId()); + + // when + assertThat(postContentImageRepository.findAll()).isEmpty(); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/repository/PostCustomRepositoryImplTest.java b/backend/src/test/java/com/votogether/domain/post/repository/PostCustomRepositoryImplTest.java new file mode 100644 index 000000000..3b4ed82b7 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/repository/PostCustomRepositoryImplTest.java @@ -0,0 +1,520 @@ +package com.votogether.domain.post.repository; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.test.RepositoryTest; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +class PostCustomRepositoryImplTest extends RepositoryTest { + + @Autowired + PostCustomRepositoryImpl postCustomRepository; + + @Nested + @DisplayName("마감시간, 정렬기준, 카테고리로 필터링하여 게시글 페이징 조회") + class FindPostsWithFilteringAndPaging { + + @Test + @DisplayName("마감되지 않은 게시글을 조회한다.") + void getPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.MINUTES); + Post post = postTestPersister.postBuilder().deadline(deadline).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.PROGRESS, + PostSortType.LATEST, + null, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("마감된 게시글을 조회한다.") + void getPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.MINUTES); + Post post = postTestPersister.postBuilder().deadline(deadline).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.CLOSED, + PostSortType.LATEST, + null, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("게시글을 최신순으로 조회한다.") + void getPostsByLatest() { + // given + Post postA = postTestPersister.postBuilder().save(); + Post postB = postTestPersister.postBuilder().save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.ALL, + PostSortType.LATEST, + null, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postB, postA); + }); + } + + @Test + @DisplayName("게시글을 인기순으로 조회한다.") + void getPostsByHot() { + // post + Post postA = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + Post postB = postTestPersister.postBuilder().save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionB).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.ALL, + PostSortType.HOT, + null, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postA, postB); + }); + } + + @Test + @DisplayName("특정 카테고리의 게시글을 조회한다.") + void getPostsTargetCategory() { + // given + Post post = postTestPersister.postBuilder().save(); + Category category = categoryTestPersister.builder().save(); + postTestPersister.postCategoryBuilder().post(post).category(category).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.ALL, + PostSortType.LATEST, + category.getId(), + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("카테고리 구분없이 게시글을 조회한다.") + void getPostsAllCategory() { + // given + Post post = postTestPersister.postBuilder().save(); + Category category = categoryTestPersister.builder().save(); + postTestPersister.postCategoryBuilder().post(post).category(category).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsWithFilteringAndPaging( + PostClosingType.ALL, + PostSortType.LATEST, + null, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + } + + @Nested + @DisplayName("작성자, 마감시간, 정렬기준으로 필터링하여 게시글 페이징 조회") + class FindPostsByWriterWithFilteringAndPaging { + + @Test + @DisplayName("마감되지 않은 게시글을 조회한다.") + void getPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).deadline(deadline).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByWriterWithFilteringAndPaging( + member, + PostClosingType.PROGRESS, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("마감된 게시글을 조회한다.") + void getPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).deadline(deadline).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByWriterWithFilteringAndPaging( + member, + PostClosingType.CLOSED, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("게시글을 최신순으로 조회한다.") + void getPostsByLatest() { + // given + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().writer(member).save(); + Post postB = postTestPersister.postBuilder().writer(member).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByWriterWithFilteringAndPaging( + member, + PostClosingType.ALL, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postB, postA); + }); + } + + @Test + @DisplayName("게시글을 인기순으로 조회한다.") + void getPostsByHot() { + // post + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().writer(member).save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + Post postB = postTestPersister.postBuilder().writer(member).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionB).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByWriterWithFilteringAndPaging( + member, + PostClosingType.ALL, + PostSortType.HOT, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postA, postB); + }); + } + + } + + @Nested + @DisplayName("키워드, 마감시간, 정렬기준으로 필터링하여 게시글 페이징 조회") + class FindSearchPostsWithFilteringAndPaging { + + @Test + @DisplayName("마감되지 않은 게시글을 조회한다.") + void getPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder() + .writer(member) + .title("votogether") + .deadline(deadline) + .save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findSearchPostsWithFilteringAndPaging( + "votogether", + PostClosingType.PROGRESS, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("마감된 게시글을 조회한다.") + void getPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder() + .writer(member) + .title("votogether") + .deadline(deadline).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findSearchPostsWithFilteringAndPaging( + "votogether", + PostClosingType.CLOSED, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("게시글을 최신순으로 조회한다.") + void getPostsByLatest() { + // given + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder() + .writer(member) + .title("votogether1") + .save(); + Post postB = postTestPersister.postBuilder() + .writer(member) + .content("votogether2") + .save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findSearchPostsWithFilteringAndPaging( + "votogether", + PostClosingType.ALL, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postB, postA); + }); + } + + @Test + @DisplayName("게시글을 인기순으로 조회한다.") + void getPostsByHot() { + // post + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().writer(member).title("votogether1").save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + Post postB = postTestPersister.postBuilder().writer(member).content("votogether2").save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionB).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findSearchPostsWithFilteringAndPaging( + "votogether", + PostClosingType.ALL, + PostSortType.HOT, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postA, postB); + }); + } + + } + + @Nested + @DisplayName("투표자, 마감시간, 정렬기준으로 필터링하여 게시글 페이징 조회") + class FindPostsByVotedWithFilteringAndPaging { + + @Test + @DisplayName("마감되지 않은 게시글을 조회한다.") + void getPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + voteTestPersister.builder().postOption(postOption).member(member).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByVotedWithFilteringAndPaging( + member, + PostClosingType.PROGRESS, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("마감된 게시글을 조회한다.") + void getPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1).truncatedTo(ChronoUnit.MINUTES); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + voteTestPersister.builder().postOption(postOption).member(member).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByVotedWithFilteringAndPaging( + member, + PostClosingType.CLOSED, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result).containsExactly(post); + }); + } + + @Test + @DisplayName("게시글을 최신순으로 조회한다.") + void getPostsByLatest() { + // given + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().writer(member).save(); + Post postB = postTestPersister.postBuilder().writer(member).save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).member(member).save(); + voteTestPersister.builder().postOption(postOptionB).member(member).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByVotedWithFilteringAndPaging( + member, + PostClosingType.ALL, + PostSortType.LATEST, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postB, postA); + }); + } + + @Test + @DisplayName("게시글을 인기순으로 조회한다.") + void getPostsByHot() { + // post + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).member(member).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + Post postB = postTestPersister.postBuilder().writer(member).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + voteTestPersister.builder().postOption(postOptionB).member(member).save(); + + // when + Pageable pageable = PageRequest.of(0, 10); + List result = postCustomRepository.findPostsByVotedWithFilteringAndPaging( + member, + PostClosingType.ALL, + PostSortType.HOT, + pageable + ); + + // then + assertSoftly(softly -> { + softly.assertThat(result).hasSize(2); + softly.assertThat(result).containsExactly(postA, postB); + }); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/repository/PostOptionRepositoryTest.java b/backend/src/test/java/com/votogether/domain/post/repository/PostOptionRepositoryTest.java new file mode 100644 index 000000000..a9db8ddc6 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/repository/PostOptionRepositoryTest.java @@ -0,0 +1,31 @@ +package com.votogether.domain.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.votogether.domain.post.entity.Post; +import com.votogether.test.RepositoryTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class PostOptionRepositoryTest extends RepositoryTest { + + @Autowired + PostOptionRepository postOptionRepository; + + @Test + @DisplayName("게시글의 모든 게시글 옵션을 삭제한다.") + void deleteAllWithPostIdInBatch() { + // given + Post post = postTestPersister.postBuilder().save(); + postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + postTestPersister.postOptionBuilder().post(post).sequence(2).save(); + + // when + postOptionRepository.deleteAllWithPostIdInBatch(post.getId()); + + // then + assertThat(postOptionRepository.findAll()).isEmpty(); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/repository/PostRepositoryTest.java b/backend/src/test/java/com/votogether/domain/post/repository/PostRepositoryTest.java index 331233c12..84c506cbe 100644 --- a/backend/src/test/java/com/votogether/domain/post/repository/PostRepositoryTest.java +++ b/backend/src/test/java/com/votogether/domain/post/repository/PostRepositoryTest.java @@ -3,840 +3,67 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import com.votogether.domain.category.entity.Category; -import com.votogether.domain.category.repository.CategoryRepository; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.entity.vo.Gender; -import com.votogether.domain.member.entity.vo.SocialType; -import com.votogether.domain.member.repository.MemberRepository; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.PostCategory; -import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.vote.entity.Vote; -import com.votogether.domain.vote.repository.VoteRepository; -import com.votogether.test.annotation.RepositoryTest; -import com.votogether.test.fixtures.MemberFixtures; -import com.votogether.test.persister.MemberTestPersister; -import com.votogether.test.persister.PostOptionTestPersister; -import com.votogether.test.persister.PostTestPersister; -import com.votogether.test.persister.VoteTestPersister; -import java.time.LocalDateTime; -import java.util.ArrayList; +import com.votogether.test.RepositoryTest; import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -@RepositoryTest -class PostRepositoryTest { - - @Autowired - MemberRepository memberRepository; +class PostRepositoryTest extends RepositoryTest { @Autowired PostRepository postRepository; - @Autowired - PostOptionRepository postOptionRepository; - - @Autowired - PostCategoryRepository postCategoryRepository; - - @Autowired - CategoryRepository categoryRepository; - - @Autowired - VoteRepository voteRepository; - - @Autowired - MemberTestPersister memberTestPersister; - - @Autowired - PostTestPersister postTestPersister; - - @Autowired - PostOptionTestPersister postOptionTestPersister; - - @Autowired - VoteTestPersister voteTestPersister; - - @Test - @DisplayName("Post를 저장한다") - void save() { - // given - final PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - final Member member = Member.builder() - .gender(Gender.MALE) - .socialType(SocialType.KAKAO) - .nickname("user1") - .birthYear(2000) - .socialId("kakao@gmail.com") - .build(); - - final Post post = Post.builder() - .writer(member) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - memberRepository.save(member); - - // when - final Post savedPost = postRepository.save(post); - - // then - assertThat(savedPost).isNotNull(); - } - - @Test - @DisplayName("해당 멤버가 작성한 글의 개수를 확인한다.") - void countByMember() { - // given - Member member = Member.builder() - .nickname("user1") - .gender(Gender.MALE) - .birthYear(2000) - .socialType(SocialType.KAKAO) - .socialId("kakao@gmail.com") - .build(); - - PostBody postBody1 = PostBody.builder() - .title("title1") - .content("content1") - .build(); - - PostBody postBody2 = PostBody.builder() - .title("title2") - .content("content2") - .build(); - - Post post1 = Post.builder() - .writer(member) - .postBody(postBody1) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Post post2 = Post.builder() - .writer(member) - .postBody(postBody2) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - memberRepository.save(member); - postRepository.save(post1); - postRepository.save(post2); - - // when - int numberOfPosts = postRepository.countByWriter(member); - - // then - assertThat(numberOfPosts).isEqualTo(2); - } - - @Nested - @DisplayName("마감 여부와 정렬 기준으로 페이징 처리하여 게시글 목록을 조회한다.") - class FindAllByClosingTypeAndSortType { - - private List members; - private List posts; - private List categories; - - @BeforeEach - void setUp() { - members = new ArrayList<>(); - posts = new ArrayList<>(); - categories = new ArrayList<>(); - - for (int i = 0; i < 11; i++) { - members.add(memberTestPersister.builder().save()); - } - - Category categoryA = Category.builder() - .name("개발") - .build(); - - Category categoryB = Category.builder() - .name("연애") - .build(); - - categories.add(categoryRepository.save(categoryA)); - categories.add(categoryRepository.save(categoryB)); - - for (int i = 2; i > 0; i--) { - Post closedPost = postTestPersister.builder() - .writer(members.get(members.size() - 1)) - .deadline(LocalDateTime.of(2022, 12, 25, 0, 0)) - .save(); - Post notClosedPost = postTestPersister.builder() - .writer(members.get(members.size() - 1)) - .deadline(LocalDateTime.of(3022, 12, 25, 0, 0)) - .save(); - - posts.add(closedPost); - posts.add(notClosedPost); - - generatePostCategory(closedPost, categories.get(0)); - generatePostCategory(notClosedPost, categories.get(0)); - - generatePostOptionAndVote(closedPost, i + 5); - generatePostOptionAndVote(notClosedPost, i + 7); - } - } - - private void generatePostCategory(Post post, Category category) { - PostCategory postCategory = PostCategory.builder() - .post(post) - .category(category) - .build(); - postCategoryRepository.save(postCategory); - } - - private void generatePostOptionAndVote(Post post, int voteCount) { - for (int j = 0; j < 5; j++) { - PostOption postOption = postOptionTestPersister.builder().sequence(j + 1).post(post).save(); - for (int k = 0; k < voteCount; k++) { - voteTestPersister.builder().member(members.get(k)).postOption(postOption).save(); - } - } - } - - @Test - @DisplayName("마감 여부와 상관없이 게시글 목록을 최신순으로 조회한다.") - void getAllPostsOrderByLatest() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.ALL, - PostSortType.LATEST, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(3), posts.get(2)); - } - - @Test - @DisplayName("마감 여부와 상관없이 게시글 목록을 인기순으로 조회한다.") - void getAllPostsOrderByHot() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.ALL, - PostSortType.HOT, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(1), posts.get(3)); - } - - @Test - @DisplayName("진행중인 게시글 목록을 최신순으로 조회한다.") - void getProgressPostsOrderByLatest() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.PROGRESS, - PostSortType.LATEST, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(3), posts.get(1)); - } - - @Test - @DisplayName("진행중인 게시글 목록을 인기순으로 조회한다.") - void getProgressPostsOrderByHot() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.PROGRESS, - PostSortType.HOT, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(1), posts.get(3)); - } - - @Test - @DisplayName("마감된 게시글 목록을 최신순으로 조회한다.") - void getClosedPostsOrderByLatest() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.CLOSED, - PostSortType.LATEST, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(2), posts.get(0)); - } - - @Test - @DisplayName("마감된 게시글 목록을 인기순으로 조회한다.") - void getClosedPostsOrderByHot() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.CLOSED, - PostSortType.HOT, - null, - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(0), posts.get(2)); - } - - @Test - @DisplayName("특정 카테고리의 진행중인 게시글을 인기순으로 조회한다.") - void getClosedPostsOrderByHotWithCategory() { - // given - Pageable pageable = PageRequest.of(0, 5); - - // when - List result = postRepository.findAllByClosingTypeAndSortTypeAndCategoryId( - PostClosingType.PROGRESS, - PostSortType.HOT, - categories.get(0).getId(), - pageable - ); - - // then - assertThat(result).containsExactly(posts.get(1), posts.get(3)); - } - - } - @Nested - @DisplayName("회원이 투표한 게시글 목록을 조회한다.") - class FindPostsVotedByMember { + @DisplayName("회원이 작성한 게시글 조회") + class FindPostsByWriter { @Test - @DisplayName("마감된 게시글 목록을 최신순으로 가져온다.") - void findClosedPostsVotedByMember() { + @DisplayName("작성한 게시글이 존재하면 게시글 목록을 조회한다.") + void findPostsExist() { // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Member member = memberRepository.save(MemberFixtures.MALE_10.get()); - - Post openPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(openPost) - .sequence(1) - .content("치킨") - .build() - ); - - Post closedPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .build()); - - PostOption postOption1 = postOptionRepository.save( - PostOption.builder() - .post(closedPost) - .sequence(1) - .content("치킨") - .build() - ); - - Post closedPost1 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1001, 7, 12, 0, 0)) - .build() - ); - PostOption postOption2 = postOptionRepository.save( - PostOption.builder() - .post(closedPost1) - .sequence(1) - .content("치킨") - .build() - ); - - voteRepository.save(Vote.builder().member(member).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(member).postOption(postOption1).build()); - voteRepository.save(Vote.builder().member(member).postOption(postOption2).build()); + Member writer = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().writer(writer).save(); + Post postB = postTestPersister.postBuilder().writer(writer).save(); // when - PageRequest pageRequest = PageRequest.of(0, 10, PostSortType.LATEST.getVoteBaseSort()); - Slice posts = postRepository.findClosedPostsVotedByMember(member, pageRequest); + List result = postRepository.findAllByWriter(writer); // then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.getContent().get(0)).usingRecursiveComparison().isEqualTo(closedPost1) - ); + assertThat(result).containsExactly(postA, postB); } @Test - @DisplayName("마감되지 않은 게시글 목록을 투표순으로 가져온다.") - void findOpenPostsVotedByMember() { + @DisplayName("작성한 게시글이 존재하지 않으면 빈 목록을 조회한다.") + void findEmptyNotExist() { // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Member member = memberRepository.save(MemberFixtures.MALE_10.get()); - Member member1 = memberRepository.save(MemberFixtures.MALE_60.get()); - - Post openPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(openPost) - .sequence(1) - .content("치킨") - .build() - ); - - Post openPost1 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3001, 7, 12, 0, 0)) - .build() - ); - PostOption postOption1 = postOptionRepository.save( - PostOption.builder() - .post(openPost1) - .sequence(1) - .content("치킨") - .build() - ); - - Post closedPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .build() - ); - PostOption postOption2 = postOptionRepository.save( - PostOption.builder() - .post(closedPost) - .sequence(1) - .content("치킨") - .build() - ); - - voteRepository.save(Vote.builder().member(member).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(member).postOption(postOption1).build()); - voteRepository.save(Vote.builder().member(member1).postOption(postOption1).build()); - voteRepository.save(Vote.builder().member(member).postOption(postOption2).build()); + Member writer = memberTestPersister.builder().save(); // when - PageRequest pageRequest = PageRequest.of(0, 10, PostSortType.HOT.getVoteBaseSort()); - Slice posts = postRepository.findOpenPostsVotedByMember(member, pageRequest); + List result = postRepository.findAllByWriter(writer); // then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.getContent().get(0)).usingRecursiveComparison().isEqualTo(openPost1) - ); - } - - @Test - @DisplayName("모든 게시글 목록을 가져온다.") - void findPostsVotedByMember() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Member member = memberRepository.save(MemberFixtures.MALE_10.get()); - - Post openPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(openPost) - .sequence(1) - .content("치킨") - .build() - ); - - Post closedPost = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .build() - ); - PostOption postOption1 = postOptionRepository.save( - PostOption.builder() - .post(closedPost) - .sequence(1) - .content("치킨") - .build() - ); - - voteRepository.save(Vote.builder().member(member).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(member).postOption(postOption1).build()); - - // when - PageRequest pageRequest = PageRequest.of(0, 10); - Slice posts = postRepository.findPostsVotedByMember(member, pageRequest); - - // then - assertThat(posts).hasSize(2); + assertThat(result).isEmpty(); } } - @Nested - @DisplayName("키워드 검색을 통해 게시글 목록을 조회한다.") - class FindingPostsByKeyword { - - Category development; - Category love; - - Post devClosedPost; - Post devOpenPost; - Post loveClosedPost; - Post loveOpenPost; - - @BeforeEach - void setUp() { - development = categoryRepository.save(Category.builder().name("개발").build()); - love = categoryRepository.save(Category.builder().name("연애").build()); - - devClosedPost = postTestPersister.builder() - .postBody(PostBody.builder().title("자바제목").content("자바내용").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .save(); - postCategoryRepository.save(PostCategory.builder().post(devClosedPost).category(development).build()); - - devOpenPost = postTestPersister.builder() - .postBody(PostBody.builder().title("자바제목1").content("자바내용1").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .save(); - postCategoryRepository.save(PostCategory.builder().post(devOpenPost).category(development).build()); - - loveClosedPost = postTestPersister.builder() - .postBody(PostBody.builder().title("커플제목").content("커플내용").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .save(); - postCategoryRepository.save(PostCategory.builder().post(loveClosedPost).category(love).build()); - - loveOpenPost = postTestPersister.builder() - .postBody(PostBody.builder().title("커플제목1").content("커플내용1").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .save(); - postCategoryRepository.save(PostCategory.builder().post(loveOpenPost).category(love).build()); - - } - - @Test - @DisplayName("특정 카테고리에 속한 게시글 목록을 키워드를 통해 검색한다.") - void searchPostsWithCategory() { - // when - List posts = postRepository.findAllWithKeyword( - "자바", - PostClosingType.ALL, - PostSortType.LATEST, - development.getId(), - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(devOpenPost), - () -> assertThat(posts.get(1)).isEqualTo(devClosedPost) - ); - } - - @Test - @DisplayName("게시글 목록을 키워드를 통해 검색한다.(제목)") - void searchPostsWithKeywordInTitle() { - // when - List posts = postRepository.findAllWithKeyword( - "제목1", - PostClosingType.ALL, - PostSortType.LATEST, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(loveOpenPost), - () -> assertThat(posts.get(1)).isEqualTo(devOpenPost) - ); - } - - @Test - @DisplayName("게시글 목록을 키워드를 통해 검색한다.(내용)") - void searchPostsWithKeywordInContent() { - // when - List posts = postRepository.findAllWithKeyword( - "내용", - PostClosingType.ALL, - PostSortType.LATEST, - null, - PageRequest.of(0, 10) - ); - - //then - assertThat(posts).hasSize(4); - } - - @Test - @DisplayName("게시글 목록을 키워드를 통해 검색한다.(제목 + 내용)") - void searchPostsWithKeywordInTitleAndContent() { - // when - List posts = postRepository.findAllWithKeyword( - "1", - PostClosingType.ALL, - PostSortType.LATEST, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(loveOpenPost), - () -> assertThat(posts.get(1)).isEqualTo(devOpenPost) - ); - } - - } - - @Nested - @DisplayName("회원이 작성한 게시글 목록을 조회한다.") - class findPostsByWriter { - - Member writer; - Member voter; - Member voter1; - - Post openPost_V2; - Post openPost1_V1; - Post closedPost_V1; - Post closedPost1_V0; - - @BeforeEach - void setUp() { - writer = memberRepository.save(MemberFixtures.MALE_20.get()); - voter = memberRepository.save(MemberFixtures.FEMALE_OVER_90.get()); - voter1 = memberRepository.save(MemberFixtures.MALE_60.get()); - - openPost_V2 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .build() - ); - - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(openPost_V2) - .sequence(1) - .content("치킨") - .build() - ); - voteRepository.save(Vote.builder().member(voter).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(voter1).postOption(postOption).build()); - - openPost1_V1 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(3000, 7, 12, 0, 0)) - .build() - ); - - PostOption postOption3 = postOptionRepository.save( - PostOption.builder() - .post(openPost1_V1) - .sequence(1) - .content("치킨") - .build() - ); - voteRepository.save(Vote.builder().member(voter).postOption(postOption3).build()); - - closedPost_V1 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1000, 7, 12, 0, 0)) - .build()); - - PostOption postOption1 = postOptionRepository.save( - PostOption.builder() - .post(closedPost_V1) - .sequence(1) - .content("치킨") - .build() - ); - voteRepository.save(Vote.builder().member(voter).postOption(postOption1).build()); - - closedPost1_V0 = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(1001, 7, 12, 0, 0)) - .build() - ); - PostOption postOption2 = postOptionRepository.save( - PostOption.builder() - .post(closedPost1_V0) - .sequence(1) - .content("치킨") - .build() - ); - } - - @Test - @DisplayName("마감된 게시글을 최신순으로 가져온다.") - void findClosedPostsWithLatest() { - // when - List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - writer, - PostClosingType.CLOSED, - PostSortType.LATEST, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(closedPost1_V0), - () -> assertThat(posts.get(1)).isEqualTo(closedPost_V1) - ); - } - - @Test - @DisplayName("마감된 게시글을 투표순으로 가져온다.") - void findClosedPostsWithHot() { - // when - List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - writer, - PostClosingType.CLOSED, - PostSortType.HOT, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(closedPost_V1), - () -> assertThat(posts.get(1)).isEqualTo(closedPost1_V0) - ); - } - - @Test - @DisplayName("마감안된 게시글을 최신순으로 가져온다.") - void findOpenPostsWithLatest() { - // when - List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - writer, - PostClosingType.PROGRESS, - PostSortType.LATEST, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(openPost1_V1), - () -> assertThat(posts.get(1)).isEqualTo(openPost_V2) - ); - } - - @Test - @DisplayName("마감안된 게시글을 인기순으로 가져온다.") - void findOpenPostsWithHot() { - // when - List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - writer, - PostClosingType.PROGRESS, - PostSortType.HOT, - null, - PageRequest.of(0, 10) - ); - - //then - assertAll( - () -> assertThat(posts).hasSize(2), - () -> assertThat(posts.get(0)).isEqualTo(openPost_V2), - () -> assertThat(posts.get(1)).isEqualTo(openPost1_V1) - ); - } - - @Test - @DisplayName("마감여부와 관계없이 게시글을 인기순으로 조회한다.") - void findPostsByHot() { - // when - List posts = postRepository.findAllByWriterWithClosingTypeAndSortTypeAndCategoryId( - writer, - PostClosingType.ALL, - PostSortType.HOT, - null, - PageRequest.of(0, 10) - ); + @Test + @DisplayName("회원이 작성한 게시글 수를 조회한다.") + void countPostsByWriter() { + // given + Member writer = memberTestPersister.builder().save(); + postTestPersister.postBuilder().writer(writer).save(); + postTestPersister.postBuilder().writer(writer).save(); - //then - assertAll( - () -> assertThat(posts).hasSize(4), - () -> assertThat(posts.get(0)).isEqualTo(openPost_V2), - () -> assertThat(posts.get(3)).isEqualTo(closedPost1_V0) - ); - } + // when + int result = postRepository.countByWriter(writer); + // then + assertThat(result).isEqualTo(2); } @Test @@ -847,9 +74,9 @@ void findCountsByMembers() { Member member1 = memberTestPersister.builder().save(); Member member2 = memberTestPersister.builder().save(); - postTestPersister.builder().writer(member).save(); - postTestPersister.builder().writer(member1).save(); - postTestPersister.builder().writer(member1).save(); + postTestPersister.postBuilder().writer(member).save(); + postTestPersister.postBuilder().writer(member1).save(); + postTestPersister.postBuilder().writer(member1).save(); // when List postCounts = postRepository.findCountsByMembers(List.of(member, member1, member2)); diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostCommandServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostCommandServiceTest.java new file mode 100644 index 000000000..ffe273f53 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/service/PostCommandServiceTest.java @@ -0,0 +1,515 @@ +package com.votogether.domain.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.request.post.PostCreateRequest; +import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest; +import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest; +import com.votogether.domain.post.dto.request.post.PostUpdateRequest; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.repository.PostCategoryRepository; +import com.votogether.domain.post.repository.PostContentImageRepository; +import com.votogether.domain.post.repository.PostOptionRepository; +import com.votogether.domain.post.repository.PostRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.ImageException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.infra.image.ImageExceptionType; +import com.votogether.test.ServiceTest; +import jakarta.persistence.EntityManager; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +class PostCommandServiceTest extends ServiceTest { + + @Autowired + EntityManager em; + + @Autowired + PostCommandService postCommandService; + + @Autowired + PostRepository postRepository; + + @Autowired + PostCategoryRepository postCategoryRepository; + + @Autowired + PostContentImageRepository postContentImageRepository; + + @Autowired + PostOptionRepository postOptionRepository; + + @AfterEach + void tearDown( + @Value("${image.upload_directory}") String uploadDirectory + ) { + File folder = new File(uploadDirectory); + if (folder.exists()) { + deleteFileRecursive(folder); + } + } + + private static void deleteFileRecursive(File file) { + if (file.isDirectory()) { + File[] files = file.listFiles(); + if (files == null) { + return; + } + + for (File child : files) { + deleteFileRecursive(child); + } + } + file.delete(); + } + + @Test + @DisplayName("정상적인 요청이라면 게시글을 작성한다.") + void createPost() { + // given + Category categoryA = categoryTestPersister.builder().save(); + Category categoryB = categoryTestPersister.builder().save(); + PostCreateRequest postCreateRequest = mockingPostCreateRequest(List.of(categoryA.getId(), categoryB.getId())); + Member member = memberTestPersister.builder().save(); + + // when + Long postId = postCommandService.createPost(postCreateRequest, member); + + // then + Post post = postRepository.findById(postId).get(); + assertSoftly(softly -> { + softly.assertThat(post.getPostCategories()).hasSize(2); + softly.assertThat(post.getPostContentImages()).hasSize(1); + softly.assertThat(post.getPostOptions()).hasSize(2); + }); + } + + @Nested + @DisplayName("게시글 수정") + class UpdatePost { + + @Test + @DisplayName("수정된 게시글 옵션 개수가 같으면 옵션 내용이 수정된다.") + void updateSameSizeOptions() { + // given + Category categoryA = categoryTestPersister.builder().save(); + Category categoryB = categoryTestPersister.builder().save(); + PostCreateRequest postCreateRequest = mockingPostCreateRequest( + List.of( + categoryA.getId(), + categoryB.getId() + ) + ); + Member member = memberTestPersister.builder().save(); + Long postId = postCommandService.createPost(postCreateRequest, member); + PostUpdateRequest postUpdateRequest = new PostUpdateRequest( + List.of(categoryA.getId()), + "New Title", + "New Content", + "votogether.png", + mockingMultipartFile("votogether.jpg"), + List.of( + new PostOptionUpdateRequest( + null, + "option1", + "votogether.png", + null + ), + new PostOptionUpdateRequest( + null, + "option2", + null, + mockingMultipartFile("votogether.png") + ) + ), + LocalDateTime.now().plusDays(3) + ); + + // when + postCommandService.updatePost(postId, postUpdateRequest, member); + + // then + Post post = postRepository.findById(postId).get(); + assertSoftly(softly -> { + softly.assertThat(post.getPostCategories()).hasSize(1); + softly.assertThat(post.getPostContentImages()).hasSize(1); + softly.assertThat(post.getPostOptions()).hasSize(2); + }); + } + + @Test + @DisplayName("수정된 게시글 옵션 개수가 적으면 게시글 옵션이 삭제된다.") + void addPostOptions() { + // given + Category categoryA = categoryTestPersister.builder().save(); + Category categoryB = categoryTestPersister.builder().save(); + Category categoryC = categoryTestPersister.builder().save(); + PostCreateRequest postCreateRequest = mockingPostCreateRequest( + List.of( + categoryA.getId(), + categoryB.getId() + ) + ); + Member member = memberTestPersister.builder().save(); + Long postId = postCommandService.createPost(postCreateRequest, member); + PostUpdateRequest postUpdateRequest = new PostUpdateRequest( + List.of(categoryA.getId(), categoryB.getId(), categoryC.getId()), + "New Title", + "New Content", + null, + mockingMultipartFile("votogether.jpg"), + List.of( + new PostOptionUpdateRequest( + null, + "option1", + null, + null + ) + ), + LocalDateTime.now().plusDays(3) + ); + + // when + postCommandService.updatePost(postId, postUpdateRequest, member); + + // then + Post post = postRepository.findById(postId).get(); + assertSoftly(softly -> { + softly.assertThat(post.getPostCategories()).hasSize(3); + softly.assertThat(post.getPostContentImages()).hasSize(1); + softly.assertThat(post.getPostOptions()).hasSize(1); + }); + } + + @Test + @DisplayName("수정된 게시글 옵션 개수가 많으면 게시글 옵션이 추가된다.") + void removePostOptions() { + // given + Category categoryA = categoryTestPersister.builder().save(); + Category categoryB = categoryTestPersister.builder().save(); + PostCreateRequest postCreateRequest = mockingPostCreateRequest( + List.of( + categoryA.getId(), + categoryB.getId() + ) + ); + Member member = memberTestPersister.builder().save(); + Long postId = postCommandService.createPost(postCreateRequest, member); + PostUpdateRequest postUpdateRequest = new PostUpdateRequest( + List.of(categoryA.getId()), + "New Title", + "New Content", + null, + null, + List.of( + new PostOptionUpdateRequest( + null, + "option1", + "vogother.png", + null + ), + new PostOptionUpdateRequest( + null, + "option2", + "votogether.png", + mockingMultipartFile("votogether.jpg") + ), + new PostOptionUpdateRequest( + null, + "option3", + null, + null + ) + ), + LocalDateTime.now().plusDays(3) + ); + + // when + postCommandService.updatePost(postId, postUpdateRequest, member); + + // then + Post post = postRepository.findById(postId).get(); + assertSoftly(softly -> { + softly.assertThat(post.getPostCategories()).hasSize(1); + softly.assertThat(post.getPostContentImages()).hasSize(0); + softly.assertThat(post.getPostOptions()).hasSize(3); + }); + } + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void emptyPost() { + // given + Member member = memberTestPersister.builder().save(); + PostUpdateRequest postUpdateRequest = mockingPostUpdateRequest(); + + // when, then + assertThatThrownBy(() -> postCommandService.updatePost(-1L, postUpdateRequest, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { + // given + Member member = memberTestPersister.builder().save(); + PostUpdateRequest postUpdateRequest = mockingPostUpdateRequest(); + Post post = postTestPersister.postBuilder().writer(member).save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postCommandService.updatePost(post.getId(), postUpdateRequest, member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") + void postNotWriter() { + // given + Member member = memberTestPersister.builder().save(); + PostUpdateRequest postUpdateRequest = mockingPostUpdateRequest(); + Post post = postTestPersister.postBuilder().save(); + + // when, then + assertThatThrownBy(() -> postCommandService.updatePost(post.getId(), postUpdateRequest, member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 작성자가 아닙니다."); + } + + } + + @Nested + @DisplayName("게시글 조기 마감") + class ClosePostEarly { + + @Test + @DisplayName("정상적인 요청이라면 게시글을 조기 마감한다.") + void success() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + + // when + postCommandService.closePostEarly(post.getId(), member); + + // then + assertThat(post.isClosed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void emptyPost() { + // given + Member member = memberTestPersister.builder().save(); + + // when, then + assertThatThrownBy(() -> postCommandService.closePostEarly(-1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postCommandService.closePostEarly(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") + void postNotWriter() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + + // when, then + assertThatThrownBy(() -> postCommandService.closePostEarly(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 작성자가 아닙니다."); + } + + } + + @Nested + @DisplayName("게시글 삭제") + class DeletePost { + + @Test + @DisplayName("정상적인 요청이라면 게시글을 삭제한다.") + void success() { + // given + Category categoryA = categoryTestPersister.builder().save(); + Category categoryB = categoryTestPersister.builder().save(); + PostCreateRequest postCreateRequest = mockingPostCreateRequest( + List.of(categoryA.getId(), categoryB.getId())); + Member member = memberTestPersister.builder().save(); + Long postId = postCommandService.createPost(postCreateRequest, member); + + em.flush(); + em.clear(); + + // when + postCommandService.deletePost(postId, member); + + // then + assertSoftly(softly -> { + softly.assertThat(postRepository.findAll()).isEmpty(); + softly.assertThat(postCategoryRepository.findAll()).isEmpty(); + softly.assertThat(postContentImageRepository.findAll()).isEmpty(); + softly.assertThat(postOptionRepository.findAll()).isEmpty(); + }); + } + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void emptyPost() { + // given + Member member = memberTestPersister.builder().save(); + + // when, then + assertThatThrownBy(() -> postCommandService.deletePost(-1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postCommandService.deletePost(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") + void postNotWriter() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + + // when, then + assertThatThrownBy(() -> postCommandService.deletePost(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 작성자가 아닙니다."); + } + + @Test + @DisplayName("게시글을 삭제할 수 없는 상태라면 예외를 던진다.") + void invalidDelete() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 20); + + // when, then + assertThatThrownBy(() -> postCommandService.deletePost(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("일정 투표 수 이상의 게시글은 삭제할 수 없습니다."); + } + + } + + private PostCreateRequest mockingPostCreateRequest(List categoryIds) { + return new PostCreateRequest( + categoryIds, + "title", + "content", + mockingMultipartFile("votogether.png"), + List.of( + new PostOptionCreateRequest( + "option1", + mockingMultipartFile("votogether.png") + ), + new PostOptionCreateRequest( + "option2", + null + ) + ), + LocalDateTime.now().plusDays(3) + ); + } + + private PostUpdateRequest mockingPostUpdateRequest() { + return new PostUpdateRequest( + List.of(1L), + "New Title", + "New Content", + "votogether.png", + mockingMultipartFile("votogether.jpg"), + List.of( + new PostOptionUpdateRequest( + 1L, + "option1", + "votogether.png", + null + ), + new PostOptionUpdateRequest( + 2L, + "option2", + null, + mockingMultipartFile("votogether.png") + ) + ), + LocalDateTime.now().plusDays(3) + ); + } + + private MultipartFile mockingMultipartFile(String fileName) { + return new MockMultipartFile( + "images", + fileName, + MediaType.IMAGE_JPEG_VALUE, + generateMockImage() + ); + } + + private byte[] generateMockImage() { + BufferedImage image = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + throw new ImageException(ImageExceptionType.IMAGE_TRANSFER); + } + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java index 749d74d3d..1c797abb5 100644 --- a/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java +++ b/backend/src/test/java/com/votogether/domain/post/service/PostCommentServiceTest.java @@ -4,127 +4,116 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.repository.MemberRepository; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; +import com.votogether.domain.post.dto.request.comment.CommentCreateRequest; import com.votogether.domain.post.dto.request.comment.CommentUpdateRequest; import com.votogether.domain.post.dto.response.comment.CommentResponse; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.domain.post.entity.comment.Comment; -import com.votogether.domain.post.repository.CommentRepository; -import com.votogether.domain.post.repository.PostRepository; import com.votogether.global.exception.BadRequestException; import com.votogether.global.exception.NotFoundException; -import com.votogether.test.annotation.ServiceTest; +import com.votogether.test.ServiceTest; import com.votogether.test.fixtures.MemberFixtures; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -@ServiceTest -class PostCommentServiceTest { +class PostCommentServiceTest extends ServiceTest { @Autowired PostCommentService postCommentService; - @Autowired - MemberRepository memberRepository; + @Nested + @DisplayName("게시글 댓글 목록 조회") + class GetComments { - @Autowired - PostRepository postRepository; + @Test + @DisplayName("게시글 댓글 목록을 조회한다.") + void getComments() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(writer).save(); + Comment commentA = commentTestPersister.builder().post(post).writer(writer).save(); + Comment commentB = commentTestPersister.builder().post(post).writer(writer).save(); - @Autowired - CommentRepository commentRepository; + // when + List response = postCommentService.getComments(post.getId()); - @Nested - @DisplayName("게시글 댓글 등록") - class CreateComment { + // then + assertThat(response).usingRecursiveComparison() + .isEqualTo(List.of(CommentResponse.from(commentA), CommentResponse.from(commentB))); + } @Test @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") void emptyPost() { + // given, when, then + assertThatThrownBy(() -> postCommentService.getComments(-1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest("hello"); + Post post = postTestPersister.postBuilder().save(); + post.blind(); // when, then - assertThatThrownBy(() -> postCommentService.createComment(member, -1L, commentRegisterRequest)) - .isInstanceOf(NotFoundException.class) - .hasMessage("해당 게시글이 존재하지 않습니다."); + assertThatThrownBy(() -> postCommentService.getComments(post.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); } + } + + @Nested + @DisplayName("게시글 댓글 등록") + class CreateComment { + @Test @DisplayName("게시글에 댓글을 등록한다.") void createComment() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest("hello"); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("hello"); // when - postCommentService.createComment(member, post.getId(), commentRegisterRequest); + postCommentService.createComment(post.getId(), commentCreateRequest, member); // then - assertThat(commentRepository.findAll()).hasSize(1); + assertThat(postCommentService.getComments(post.getId())).hasSize(1); } - } - - @Nested - @DisplayName("게시글 댓글 목록 조회") - class GetComments { - @Test @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") void emptyPost() { - // given, when, then - assertThatThrownBy(() -> postCommentService.getComments(-1L)) + // given + Member member = MemberFixtures.MALE_20.get(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("hello"); + + // when, then + assertThatThrownBy(() -> postCommentService.createComment(-1L, commentCreateRequest, member)) .isInstanceOf(NotFoundException.class) - .hasMessage("해당 게시글이 존재하지 않습니다."); + .hasMessage("게시글이 존재하지 않습니다."); } @Test - @DisplayName("게시글 댓글 목록을 조회한다.") - void getComments() { + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment commentA = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("commentA") - .build() - ); - Comment commentB = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("commentB") - .build() - ); + Member member = MemberFixtures.MALE_20.get(); + CommentCreateRequest commentCreateRequest = new CommentCreateRequest("hello"); + Post post = postTestPersister.postBuilder().save(); + post.blind(); - // when - List response = postCommentService.getComments(post.getId()); - - // then - assertThat(response).usingRecursiveComparison() - .isEqualTo(List.of(CommentResponse.from(commentA), CommentResponse.from(commentB))); + // when, then + assertThatThrownBy(() -> postCommentService.createComment(post.getId(), commentCreateRequest, member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); } } @@ -133,251 +122,213 @@ void getComments() { @DisplayName("게시글 댓글 수정") class UpdateComment { + @Test + @DisplayName("게시글의 댓글을 수정한다.") + void deleteComment() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); + CommentUpdateRequest request = new CommentUpdateRequest("hello"); + + // when + postCommentService.updateComment(post.getId(), comment.getId(), request, member); + + // then + assertThat(comment.getContent()).isEqualTo("hello"); + } + @Test @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") void emptyPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + Member member = MemberFixtures.MALE_20.get(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then assertThatThrownBy(() -> postCommentService.updateComment(-1L, 1L, request, member)) .isInstanceOf(NotFoundException.class) - .hasMessage("해당 게시글이 존재하지 않습니다."); + .hasMessage("게시글이 존재하지 않습니다."); } @Test @DisplayName("존재하지 않는 댓글이라면 예외를 던진다.") void emptyComment() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); + Member member = MemberFixtures.MALE_20.get(); + Post post = postTestPersister.postBuilder().save(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), -1L, request, member)) .isInstanceOf(NotFoundException.class) - .hasMessage("해당 댓글이 존재하지 않습니다."); + .hasMessage("댓글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); + CommentUpdateRequest request = new CommentUpdateRequest("hello"); + post.blind(); + + // when, then + assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), comment.getId(), request, member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("블라인드된 댓글이라면 예외를 던진다.") + void blindComment() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); + CommentUpdateRequest request = new CommentUpdateRequest("hello"); + comment.blind(); + + // when, then + assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), comment.getId(), request, member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 댓글은 접근할 수 없습니다."); } @Test @DisplayName("댓글의 게시글과 일치하지 않으면 예외를 던진다.") void invalidBelongPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post postA = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Post postB = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleB").content("contentB").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(member) - .post(postA) - .content("comment") - .build() - ); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().writer(member).save(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then - assertThatThrownBy(() -> postCommentService.updateComment(postB.getId(), comment.getId(), request, member)) + assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), comment.getId(), request, member)) .isInstanceOf(BadRequestException.class) - .hasMessage("댓글의 게시글 정보와 일치하지 않습니다."); + .hasMessage("게시글의 댓글이 아닙니다."); } @Test @DisplayName("댓글의 작성자가 아니라면 예외를 던진다.") void invalidWriter() { // given - Member memberA = memberRepository.save(MemberFixtures.MALE_20.get()); - Member memberB = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(memberA) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(memberB) - .post(post) - .content("comment") - .build() - ); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).save(); CommentUpdateRequest request = new CommentUpdateRequest("hello"); // when, then - assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), comment.getId(), request, memberA)) + assertThatThrownBy(() -> postCommentService.updateComment(post.getId(), comment.getId(), request, member)) .isInstanceOf(BadRequestException.class) .hasMessage("댓글 작성자가 아닙니다."); } + } + + @Nested + @DisplayName("게시글 댓글 삭제") + class DeleteComment { + @Test - @DisplayName("게시글의 댓글을 수정한다.") + @DisplayName("게시글의 댓글을 삭제한다.") void deleteComment() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("comment") - .build() - ); - CommentUpdateRequest request = new CommentUpdateRequest("hello"); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); // when - postCommentService.updateComment(post.getId(), comment.getId(), request, member); + postCommentService.deleteComment(post.getId(), comment.getId(), member); // then - assertThat(comment.getContent()).isEqualTo("hello"); + assertThat(postCommentService.getComments(post.getId())).isEmpty(); } - } - - @Nested - @DisplayName("게시글 댓글 삭제") - class DeleteComment { - @Test @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") void emptyPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); + Member member = MemberFixtures.MALE_20.get(); // when, then assertThatThrownBy(() -> postCommentService.deleteComment(-1L, 1L, member)) .isInstanceOf(NotFoundException.class) - .hasMessage("해당 게시글이 존재하지 않습니다."); + .hasMessage("게시글이 존재하지 않습니다."); } @Test @DisplayName("존재하지 않는 댓글이라면 예외를 던진다.") void emptyComment() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); + Member member = MemberFixtures.MALE_20.get(); + Post post = postTestPersister.postBuilder().save(); // when, then assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), -1L, member)) .isInstanceOf(NotFoundException.class) - .hasMessage("해당 댓글이 존재하지 않습니다."); + .hasMessage("댓글이 존재하지 않습니다."); } @Test - @DisplayName("댓글의 게시글과 일치하지 않으면 예외를 던진다.") - void invalidBelongPost() { + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void blindPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post postA = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Post postB = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleB").content("contentB").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(member) - .post(postA) - .content("comment") - .build() - ); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); + post.blind(); // when, then - assertThatThrownBy(() -> postCommentService.deleteComment(postB.getId(), comment.getId(), member)) + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), member)) .isInstanceOf(BadRequestException.class) - .hasMessage("댓글의 게시글 정보와 일치하지 않습니다."); + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); } @Test - @DisplayName("댓글의 작성자가 아니라면 예외를 던진다.") - void invalidWriter() { + @DisplayName("블라인드된 댓글이라면 예외를 던진다.") + void blindComment() { // given - Member memberA = memberRepository.save(MemberFixtures.MALE_20.get()); - Member memberB = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(memberA) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(memberB) - .post(post) - .content("comment") - .build() - ); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).writer(member).save(); + comment.blind(); // when, then - assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), memberA)) + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), member)) .isInstanceOf(BadRequestException.class) - .hasMessage("댓글 작성자가 아닙니다."); + .hasMessage("신고에 의해 숨겨진 댓글은 접근할 수 없습니다."); } @Test - @DisplayName("게시글의 댓글을 삭제한다.") - void deleteComment() { + @DisplayName("댓글의 게시글과 일치하지 않으면 예외를 던진다.") + void invalidBelongPost() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("titleA").content("contentA").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - Comment comment = commentRepository.save( - Comment.builder() - .member(member) - .post(post) - .content("comment") - .build() - ); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().writer(member).save(); - // when - postCommentService.deleteComment(post.getId(), comment.getId(), member); + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글의 댓글이 아닙니다."); + } - // then - assertThat(commentRepository.findAll()).isEmpty(); + @Test + @DisplayName("댓글의 작성자가 아니라면 예외를 던진다.") + void invalidWriter() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + Comment comment = commentTestPersister.builder().post(post).save(); + + // when, then + assertThatThrownBy(() -> postCommentService.deleteComment(post.getId(), comment.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("댓글 작성자가 아닙니다."); } } diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostGuestServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostGuestServiceTest.java new file mode 100644 index 000000000..3206cab29 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/service/PostGuestServiceTest.java @@ -0,0 +1,375 @@ +package com.votogether.domain.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostOptionVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostRankingResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.post.PostVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostWriterResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.vote.entity.Vote; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.test.ServiceTest; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; + +class PostGuestServiceTest extends ServiceTest { + + @Autowired + PostGuestService postGuestService; + + @Autowired + EntityManager em; + + @Nested + @DisplayName("비회원 게시글 목록 조회") + class GuestGetPosts { + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void canCheckResultsClosedPosts() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = postGuestService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("진행중인 게시글은 결과를 확인할 수 없다.") + void cannotCheckResultsOpenPosts() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + + // when + List result = postGuestService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + } + + @Nested + @DisplayName("비회원 게시글 상세 조회") + class GuestGetPost { + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void canCheckResultsClosedPost() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + PostResponse result = postGuestService.getPost(post.getId()); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("진행중인 게시글은 결과를 확인할 수 없다.") + void cannotCheckResultsOpenPosts() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + + // when + PostResponse result = postGuestService.getPost(post.getId()); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void throwExceptionNotFoundPost() { + // given + Long postId = -1L; + + // when, then + assertThatThrownBy(() -> postGuestService.getPost(postId)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void throwExceptionBlindPost() { + // given + Post post = postTestPersister.postBuilder().save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postGuestService.getPost(post.getId())) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + } + + @Nested + @DisplayName("비회원 게시글 목록 검색") + class GuestSearchPosts { + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void canCheckResultsClosedPosts() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postGuestService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("진행중인 게시글은 결과를 확인할 수 없다.") + void cannotCheckResultsOpenPosts() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + + // when + List result = + postGuestService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + } + + @Nested + @DisplayName("상위 10개 인기 게시물을 조회한다.") + class Ranking { + + @Test + @DisplayName("중복 순위가 있는 경우") + void getRanking() { + // given + List posts = new ArrayList<>(); + List postOptions = new ArrayList<>(); + + for (int i = 0; i < 11; i++) { + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).save(); + posts.add(post); + postOptions.add(postOption); + } + + for (int i = 0; i < 10; i++) { + for (int j = 0; j < i + 1; j++) { + voteTestPersister.builder().postOption(postOptions.get(i)).save(); + } + } + + voteTestPersister.builder().postOption(postOptions.get(10)).save(); + voteTestPersister.builder().postOption(postOptions.get(10)).save(); + voteTestPersister.builder().postOption(postOptions.get(10)).save(); + voteTestPersister.builder().postOption(postOptions.get(7)).save(); + + /* + index |0| |1| |2| |3| |4| |5| |6| |7| |8| |9| |10| + voteCount |1| |2| |3| |4| |5| |6| |7| |9| |9| |10| |3| + ranking |11| |10| |8| |7| |6| |5| |4| |2| |2| |1| |8| + */ + + em.clear(); + em.flush(); + + // when + List rankings = postGuestService.getRanking(); + + // then + assertAll( + () -> assertThat(rankings).hasSize(10), + () -> assertThat(rankings.get(0).ranking()).isEqualTo(1), + () -> assertThat(rankings.get(1).ranking()).isEqualTo(2), + () -> assertThat(rankings.get(2).ranking()).isEqualTo(2), + () -> assertThat(rankings.get(3).ranking()).isEqualTo(4), + () -> assertThat(rankings.get(4).ranking()).isEqualTo(5), + () -> assertThat(rankings.get(5).ranking()).isEqualTo(6), + () -> assertThat(rankings.get(6).ranking()).isEqualTo(7), + () -> assertThat(rankings.get(7).ranking()).isEqualTo(8), + () -> assertThat(rankings.get(8).ranking()).isEqualTo(8), + () -> assertThat(rankings.get(9).ranking()).isEqualTo(10), + () -> assertThat(rankings.get(0).postSummaryResponse().id()).isEqualTo(posts.get(9).getId()), + () -> assertThat(rankings.get(3).postSummaryResponse().id()).isEqualTo(posts.get(6).getId()), + () -> assertThat(rankings.get(4).postSummaryResponse().id()).isEqualTo(posts.get(5).getId()), + () -> assertThat(rankings.get(5).postSummaryResponse().id()).isEqualTo(posts.get(4).getId()), + () -> assertThat(rankings.get(6).postSummaryResponse().id()).isEqualTo(posts.get(3).getId()), + () -> assertThat(rankings.get(9).postSummaryResponse().id()).isEqualTo(posts.get(1).getId()), + () -> assertThat(List.of( + rankings.get(7).postSummaryResponse().id(), + rankings.get(8).postSummaryResponse().id() + ).containsAll(List.of(posts.get(10).getId(), posts.get(2).getId()))), + () -> assertThat(List.of( + rankings.get(1).postSummaryResponse().id(), + rankings.get(2).postSummaryResponse().id() + ).containsAll(List.of(posts.get(7).getId(), posts.get(8).getId()))) + ); + } + + @Test + @DisplayName("중복 순위가 없는 경우") + void getRanking1() { + // given + List posts = new ArrayList<>(); + List postOptions = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).save(); + posts.add(post); + postOptions.add(postOption); + } + + for (int i = 0; i < 10; i++) { + for (int j = 0; j < i + 1; j++) { + voteTestPersister.builder().postOption(postOptions.get(i)).save(); + } + } + + /* + index |0| |1| |2| |3| |4| |5| |6| |7| |8| |9| + voteCount |1| |2| |3| |4| |5| |6| |7| |8| |9| |10| + ranking |10| |9| |8| |7| |6| |5| |4| |3| |2| |1| + */ + + em.clear(); + em.flush(); + + // when + List rankings = postGuestService.getRanking(); + + // then + assertAll( + () -> assertThat(rankings).hasSize(10), + () -> assertThat(rankings.get(0).ranking()).isEqualTo(1), + () -> assertThat(rankings.get(1).ranking()).isEqualTo(2), + () -> assertThat(rankings.get(2).ranking()).isEqualTo(3), + () -> assertThat(rankings.get(3).ranking()).isEqualTo(4), + () -> assertThat(rankings.get(4).ranking()).isEqualTo(5), + () -> assertThat(rankings.get(5).ranking()).isEqualTo(6), + () -> assertThat(rankings.get(6).ranking()).isEqualTo(7), + () -> assertThat(rankings.get(7).ranking()).isEqualTo(8), + () -> assertThat(rankings.get(8).ranking()).isEqualTo(9), + () -> assertThat(rankings.get(9).ranking()).isEqualTo(10), + () -> assertThat(rankings.get(0).postSummaryResponse().id()).isEqualTo(posts.get(9).getId()), + () -> assertThat(rankings.get(1).postSummaryResponse().id()).isEqualTo(posts.get(8).getId()), + () -> assertThat(rankings.get(2).postSummaryResponse().id()).isEqualTo(posts.get(7).getId()), + () -> assertThat(rankings.get(3).postSummaryResponse().id()).isEqualTo(posts.get(6).getId()), + () -> assertThat(rankings.get(4).postSummaryResponse().id()).isEqualTo(posts.get(5).getId()), + () -> assertThat(rankings.get(5).postSummaryResponse().id()).isEqualTo(posts.get(4).getId()), + () -> assertThat(rankings.get(6).postSummaryResponse().id()).isEqualTo(posts.get(3).getId()), + () -> assertThat(rankings.get(7).postSummaryResponse().id()).isEqualTo(posts.get(2).getId()), + () -> assertThat(rankings.get(8).postSummaryResponse().id()).isEqualTo(posts.get(1).getId()), + () -> assertThat(rankings.get(9).postSummaryResponse().id()).isEqualTo(posts.get(0).getId()) + ); + } + + } + + private PostResponse expectedResponse( + final Post post, + final Member writer, + final PostOption postOption, + final long selectedOptionId, + final int totalVoteCount, + final int postOptionVoteCount, + final double votePercent + ) { + return new PostResponse( + post.getId(), + new PostWriterResponse(writer.getId(), writer.getNickname()), + post.getTitle(), + post.getContent(), + "", + Collections.emptyList(), + post.getCreatedAt(), + post.getDeadline(), + 0, + 0, + new PostVoteResultResponse( + selectedOptionId, + totalVoteCount, + List.of( + new PostOptionVoteResultResponse( + postOption.getId(), + postOption.getContent(), + "", + postOptionVoteCount, + votePercent + ) + ) + ) + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostQueryServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostQueryServiceTest.java new file mode 100644 index 000000000..a246e587e --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/post/service/PostQueryServiceTest.java @@ -0,0 +1,699 @@ +package com.votogether.domain.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.entity.vo.AgeRange; +import com.votogether.domain.member.entity.vo.Gender; +import com.votogether.domain.post.dto.response.post.PostOptionVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.dto.response.post.PostVoteResultResponse; +import com.votogether.domain.post.dto.response.post.PostWriterResponse; +import com.votogether.domain.post.dto.response.vote.VoteCountForAgeGroupResponse; +import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.vote.entity.Vote; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.test.ServiceTest; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; + +class PostQueryServiceTest extends ServiceTest { + + @Autowired + PostQueryService postQueryService; + + @Nested + @DisplayName("회원 게시글 목록 조회") + class FindPosts { + + @Test + @DisplayName("내가 쓴 게시글도 아니고 투표한 게시글도 아닐 때 진행중인 게시글은 결과를 확인할 수 없다.") + void findPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + post.addPostOption(postOption); + + // when + List result = + postQueryService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void findPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("내가 쓴 게시글은 결과를 확인할 수 있다.") + void findPostsWritten() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null, member); + + // then + PostResponse expected = expectedResponse(post, member, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("투표한 게시글은 결과를 확인할 수 있다.") + void findPostsVoted() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).member(member).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.getPosts(0, PostClosingType.ALL, PostSortType.LATEST, null, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, postOption.getId(), 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + } + + @Nested + @DisplayName("회원 게시글 조회") + class FindPost { + + @Test + @DisplayName("존재하지 않는 게시글이면 예외를 던진다.") + void throwExceptionNotExistPost() { + // given + Member member = memberTestPersister.builder().save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getPost(-1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이면 예외를 던진다.") + void throwExceptionBlindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postQueryService.getPost(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("내가 쓴 게시글도 아니고 투표한 게시글도 아닐 때 진행중인 게시글은 결과를 확인할 수 없다.") + void findPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + post.addPostOption(postOption); + + // when + PostResponse result = postQueryService.getPost(post.getId(), member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void findPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + PostResponse result = postQueryService.getPost(post.getId(), member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("내가 쓴 게시글은 결과를 확인할 수 없다.") + void findPostsWritten() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + PostResponse result = postQueryService.getPost(post.getId(), member); + + // then + PostResponse expected = expectedResponse(post, member, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("투표한 게시글은 결과를 확인할 수 있다.") + void findPostsVoted() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).member(member).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + PostResponse result = postQueryService.getPost(post.getId(), member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, postOption.getId(), 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + } + + @Nested + @DisplayName("회원 게시글 검색") + class SearchPosts { + + @Test + @DisplayName("내가 쓴 게시글도 아니고 투표한 게시글도 아닐 때 진행중인 게시글은 결과를 확인할 수 없다.") + void searchPostsOpen() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + post.addPostOption(postOption); + + // when + List result = + postQueryService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, -1, -1, 0.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("마감된 게시글은 결과를 확인할 수 있다.") + void searchPostsClosed() { + // given + LocalDateTime deadline = LocalDateTime.now().minusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("내가 쓴 게시글은 결과를 확인할 수 없다.") + void searchPostsWritten() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expected = expectedResponse(post, member, postOption, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + @Test + @DisplayName("투표한 게시글은 결과를 확인할 수 있다.") + void searchPostsVoted() { + // given + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + Member writer = memberTestPersister.builder().save(); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(deadline).writer(writer).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).member(member).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + ReflectionTestUtils.setField(postOption, "voteCount", 1); + + // when + List result = + postQueryService.searchPosts(post.getTitle(), 0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expected = expectedResponse(post, writer, postOption, postOption.getId(), 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expected)); + } + + } + + @Test + @DisplayName("내가 쓴 게시글 목록을 조회한다.") + void findPostWritten() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + LocalDateTime closed = LocalDateTime.now().minusDays(1); + Member member = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().deadline(open).writer(member).save(); + Post postB = postTestPersister.postBuilder().deadline(closed).writer(member).save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + Vote voteA = voteTestPersister.builder().postOption(postOptionA).save(); + Vote voteB = voteTestPersister.builder().postOption(postOptionB).save(); + postOptionA.addVote(voteA); + postOptionB.addVote(voteB); + postA.addPostOption(postOptionA); + postB.addPostOption(postOptionB); + ReflectionTestUtils.setField(postOptionA, "voteCount", 1); + ReflectionTestUtils.setField(postOptionB, "voteCount", 1); + + // when + List result = + postQueryService.getPostsWrittenByMe(0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expectedA = expectedResponse(postA, member, postOptionA, 0L, 1, 1, 100.0); + PostResponse expectedB = expectedResponse(postB, member, postOptionB, 0L, 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expectedB, expectedA)); + } + + @Test + @DisplayName("내가 투표한 게시글 목록을 조회한다.") + void findPostsVoted() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + LocalDateTime closed = LocalDateTime.now().minusDays(1); + Member member = memberTestPersister.builder().save(); + Member writer = memberTestPersister.builder().save(); + Post postA = postTestPersister.postBuilder().deadline(open).writer(writer).save(); + Post postB = postTestPersister.postBuilder().deadline(closed).writer(writer).save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(postA).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(postB).sequence(1).save(); + Vote voteA = voteTestPersister.builder().postOption(postOptionA).member(member).save(); + Vote voteB = voteTestPersister.builder().postOption(postOptionB).member(member).save(); + postOptionA.addVote(voteA); + postOptionB.addVote(voteB); + postA.addPostOption(postOptionA); + postB.addPostOption(postOptionB); + ReflectionTestUtils.setField(postOptionA, "voteCount", 1); + ReflectionTestUtils.setField(postOptionB, "voteCount", 1); + + // when + List result = + postQueryService.getPostsVotedByMe(0, PostClosingType.ALL, PostSortType.LATEST, member); + + // then + PostResponse expectedA = expectedResponse(postA, writer, postOptionA, postOptionA.getId(), 1, 1, 100.0); + PostResponse expectedB = expectedResponse(postB, writer, postOptionB, postOptionB.getId(), 1, 1, 100.0); + assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expectedB, expectedA)); + } + + @Nested + @DisplayName("게시글의 투표 통계 조회") + class GetPostVoteStatistics { + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void throwExceptionNotExist() { + // given + Member member = memberTestPersister.builder().save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteStatistics(-1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void throwExceptionBlindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteStatistics(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") + void throwExceptionNotWriter() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(open).save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteStatistics(post.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 작성자가 아닙니다."); + } + + @Test + @DisplayName("게시글 작성자라면 투표 통계를 조회한다.") + void getPostVoteStatistics() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Member voter = memberTestPersister.builder().birthYear(1993).gender(Gender.MALE).save(); + Post post = postTestPersister.postBuilder().deadline(open).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).member(voter).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + + // when + VoteOptionStatisticsResponse result = postQueryService.getVoteStatistics(post.getId(), member); + + // then + VoteOptionStatisticsResponse expected = new VoteOptionStatisticsResponse( + 1, + 1, + 0, + List.of( + new VoteCountForAgeGroupResponse( + AgeRange.UNDER_TEENS.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.TEENS.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.TWENTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.THIRTIES.getName(), + 1, + 1, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.FORTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.FIFTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.OVER_SIXTIES.getName(), + 0, + 0, + 0 + ) + ) + ); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + } + + @Nested + @DisplayName("게시글 옵션의 투표 통계 조회") + class GetPostOptionVoteStatistics { + + @Test + @DisplayName("존재하지 않는 게시글이라면 예외를 던진다.") + void throwExceptionNotExistPost() { + // given + Member member = memberTestPersister.builder().save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteOptionStatistics(-1L, 1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("블라인드된 게시글이라면 예외를 던진다.") + void throwExceptionBlindPost() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + post.blind(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteOptionStatistics(post.getId(), postOption.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("존재하지 않는 게시글 옵션이라면 예외를 던진다.") + void throwExceptionNotExistOption() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(member).save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteOptionStatistics(post.getId(), -1L, member)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글 투표 옵션이 존재하지 않습니다."); + } + + @Test + @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") + void throwExceptionNotWriter() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(open).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteOptionStatistics(post.getId(), postOption.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글 작성자가 아닙니다."); + } + + @Test + @DisplayName("게시글의 옵션이 아니라면 예외를 던진다.") + void throwExceptionNotBelongPost() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().deadline(open).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().sequence(1).save(); + + // when, then + assertThatThrownBy(() -> postQueryService.getVoteOptionStatistics(post.getId(), postOption.getId(), member)) + .isInstanceOf(BadRequestException.class) + .hasMessage("게시글의 투표 옵션이 아닙니다."); + } + + @Test + @DisplayName("게시글 작성자라면 투표 통계를 조회한다.") + void getPostOptionVoteStatistics() { + // given + LocalDateTime open = LocalDateTime.now().plusDays(1); + Member member = memberTestPersister.builder().save(); + Member voter = memberTestPersister.builder().birthYear(1993).gender(Gender.MALE).save(); + Post post = postTestPersister.postBuilder().deadline(open).writer(member).save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + Vote vote = voteTestPersister.builder().postOption(postOption).member(voter).save(); + postOption.addVote(vote); + post.addPostOption(postOption); + + // when + VoteOptionStatisticsResponse result = + postQueryService.getVoteOptionStatistics(post.getId(), postOption.getId(), member); + + // then + VoteOptionStatisticsResponse expected = new VoteOptionStatisticsResponse( + 1, + 1, + 0, + List.of( + new VoteCountForAgeGroupResponse( + AgeRange.UNDER_TEENS.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.TEENS.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.TWENTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.THIRTIES.getName(), + 1, + 1, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.FORTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.FIFTIES.getName(), + 0, + 0, + 0 + ), + new VoteCountForAgeGroupResponse( + AgeRange.OVER_SIXTIES.getName(), + 0, + 0, + 0 + ) + ) + ); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + } + + private PostResponse expectedResponse( + final Post post, + final Member writer, + final PostOption postOption, + final long selectedOptionId, + final int totalVoteCount, + final int postOptionVoteCount, + final double votePercent + ) { + return new PostResponse( + post.getId(), + new PostWriterResponse(writer.getId(), writer.getNickname()), + post.getTitle(), + post.getContent(), + "", + Collections.emptyList(), + post.getCreatedAt(), + post.getDeadline(), + 0, + 0, + new PostVoteResultResponse( + selectedOptionId, + totalVoteCount, + List.of( + new PostOptionVoteResultResponse( + postOption.getId(), + postOption.getContent(), + "", + postOptionVoteCount, + votePercent + ) + ) + ) + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/votogether/domain/post/service/PostServiceTest.java deleted file mode 100644 index 0bb486c11..000000000 --- a/backend/src/test/java/com/votogether/domain/post/service/PostServiceTest.java +++ /dev/null @@ -1,1287 +0,0 @@ -package com.votogether.domain.post.service; - -import static com.votogether.test.fixtures.MemberFixtures.FEMALE_10; -import static com.votogether.test.fixtures.MemberFixtures.FEMALE_70; -import static com.votogether.test.fixtures.MemberFixtures.FEMALE_80; -import static com.votogether.test.fixtures.MemberFixtures.MALE_10; -import static com.votogether.test.fixtures.MemberFixtures.MALE_20; -import static com.votogether.test.fixtures.MemberFixtures.MALE_30; -import static com.votogether.test.fixtures.MemberFixtures.MALE_60; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; - -import com.votogether.domain.category.entity.Category; -import com.votogether.domain.category.repository.CategoryRepository; -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.entity.vo.Gender; -import com.votogether.domain.member.entity.vo.SocialType; -import com.votogether.domain.member.repository.MemberCategoryRepository; -import com.votogether.domain.member.repository.MemberRepository; -import com.votogether.domain.post.dto.request.comment.CommentRegisterRequest; -import com.votogether.domain.post.dto.request.post.PostCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionCreateRequest; -import com.votogether.domain.post.dto.request.post.PostOptionUpdateRequest; -import com.votogether.domain.post.dto.request.post.PostUpdateRequest; -import com.votogether.domain.post.dto.response.post.CategoryResponse; -import com.votogether.domain.post.dto.response.post.PostDetailResponse; -import com.votogether.domain.post.dto.response.post.PostOptionDetailResponse; -import com.votogether.domain.post.dto.response.post.PostRankingResponse; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.dto.response.post.WriterResponse; -import com.votogether.domain.post.dto.response.vote.VoteDetailResponse; -import com.votogether.domain.post.dto.response.vote.VoteOptionStatisticsResponse; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.PostCategory; -import com.votogether.domain.post.entity.PostContentImage; -import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.entity.PostOptions; -import com.votogether.domain.post.entity.comment.Comment; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.post.exception.PostExceptionType; -import com.votogether.domain.post.repository.CommentRepository; -import com.votogether.domain.post.repository.PostCategoryRepository; -import com.votogether.domain.post.repository.PostContentImageRepository; -import com.votogether.domain.post.repository.PostOptionRepository; -import com.votogether.domain.post.repository.PostRepository; -import com.votogether.domain.vote.entity.Vote; -import com.votogether.domain.vote.repository.VoteRepository; -import com.votogether.domain.vote.service.VoteService; -import com.votogether.global.exception.BadRequestException; -import com.votogether.global.exception.NotFoundException; -import com.votogether.test.annotation.ServiceTest; -import com.votogether.test.fixtures.CategoryFixtures; -import com.votogether.test.fixtures.MemberFixtures; -import com.votogether.test.persister.MemberTestPersister; -import com.votogether.test.persister.PostOptionTestPersister; -import com.votogether.test.persister.PostTestPersister; -import com.votogether.test.persister.VoteTestPersister; -import jakarta.persistence.EntityManager; -import java.io.FileInputStream; -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; - -@ServiceTest -class PostServiceTest { - - @Autowired - EntityManager entityManager; - - @Autowired - MemberRepository memberRepository; - - @Autowired - MemberCategoryRepository memberCategoryRepository; - - @Autowired - PostRepository postRepository; - - @Autowired - PostOptionRepository postOptionRepository; - - @Autowired - CategoryRepository categoryRepository; - - @Autowired - VoteRepository voteRepository; - - @Autowired - PostService postService; - - @Autowired - VoteService voteService; - - @Autowired - MemberTestPersister memberTestPersister; - - @Autowired - PostTestPersister postTestPersister; - - @Autowired - PostOptionTestPersister postOptionTestPersister; - - @Autowired - VoteTestPersister voteTestPersister; - - @Autowired - PostCategoryRepository postCategoryRepository; - - @Autowired - PostContentImageRepository postContentImageRepository; - - @Autowired - CommentRepository commentRepository; - - @Autowired - PostCommentService postCommentService; - - @Test - @DisplayName("게시글을 등록한다") - void save() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - // when - Long savedPostId = postService.save(postCreateRequest, member, List.of(file3), List.of(file1, file2)); - - // then - assertThat(savedPostId).isNotNull(); - } - - @Nested - @DisplayName("게시글에 대한 투표 통계 조회 시 ") - class GetVoteStatistics { - - @Test - @DisplayName("게시글이 존재하지 않으면 예외를 던진다.") - void throwExceptionNonExistPost() { - // given, when, then - assertThatThrownBy(() -> postService.getVoteStatistics(-1L, MemberFixtures.MALE_20.get())).isInstanceOf( - NotFoundException.class).hasMessage("해당 게시글이 존재하지 않습니다."); - } - - @Test - @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") - void throwExceptionNotWriter() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Member reader = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - - // when, then - assertThatThrownBy(() -> postService.getVoteStatistics(post.getId(), reader)).isInstanceOf( - BadRequestException.class).hasMessage("해당 게시글 작성자가 아닙니다."); - } - - @Test - @DisplayName("전체 투표 통계를 조회한다.") - void getVoteStatistics() { - // given - Member femaleEarly10 = memberRepository.save(MemberFixtures.FEMALE_10.get()); - Member male10 = memberRepository.save(MemberFixtures.MALE_10.get()); - Member male60 = memberRepository.save(MemberFixtures.MALE_60.get()); - Member female70 = memberRepository.save(MemberFixtures.FEMALE_70.get()); - Member female80 = memberRepository.save(MemberFixtures.FEMALE_80.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - PostOption postOptionA = postOptionRepository.save( - PostOption.builder().post(post).sequence(1).content("치킨").build()); - PostOption postOptionB = postOptionRepository.save( - PostOption.builder().post(post).sequence(2).content("피자").build()); - - voteRepository.save(Vote.builder().member(femaleEarly10).postOption(postOptionA).build()); - voteRepository.save(Vote.builder().member(male10).postOption(postOptionB).build()); - voteRepository.save(Vote.builder().member(male60).postOption(postOptionA).build()); - voteRepository.save(Vote.builder().member(female70).postOption(postOptionB).build()); - voteRepository.save(Vote.builder().member(female80).postOption(postOptionA).build()); - - // when - VoteOptionStatisticsResponse response = postService.getVoteStatistics(post.getId(), writer); - - // then - assertAll(() -> assertThat(response.totalVoteCount()).isEqualTo(5), - () -> assertThat(response.totalMaleCount()).isEqualTo(2), - () -> assertThat(response.totalFemaleCount()).isEqualTo(3), - () -> assertThat(response.ageGroup()).hasSize(7), - () -> assertThat(response.ageGroup().get(1).ageGroup()).isEqualTo("10대"), - () -> assertThat(response.ageGroup().get(1).voteCount()).isEqualTo(2), - () -> assertThat(response.ageGroup().get(1).maleCount()).isEqualTo(1), - () -> assertThat(response.ageGroup().get(1).femaleCount()).isEqualTo(1)); - } - - } - - @Nested - @DisplayName("게시글 투표 옵션에 대한 투표 통계 조회 시 ") - class GetVoteOptionStatistics { - - @Test - @DisplayName("게시글이 존재하지 않으면 예외를 던진다.") - void throwExceptionNonExistPost() { - // given, when, then - assertThatThrownBy( - () -> postService.getVoteOptionStatistics(-1L, 1L, MemberFixtures.MALE_20.get())).isInstanceOf( - NotFoundException.class).hasMessage("해당 게시글이 존재하지 않습니다."); - } - - @Test - @DisplayName("게시글 투표 옵션이 존재하지 않으면 예외를 던진다.") - void throwExceptionNonExistOption() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - - // when, then - assertThatThrownBy(() -> postService.getVoteOptionStatistics(post.getId(), -1L, writer)).isInstanceOf( - NotFoundException.class).hasMessage("해당 게시글 투표 옵션이 존재하지 않습니다."); - } - - @Test - @DisplayName("게시글 투표 옵션이 게시글에 속하지 않으면 예외를 던진다.") - void throwExceptionNotBelongToPost() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post1 = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - Post post2 = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - PostOption postOption = postOptionRepository.save( - PostOption.builder().post(post2).sequence(1).content("치킨").build()); - - // when, then - assertThatThrownBy( - () -> postService.getVoteOptionStatistics(post1.getId(), postOption.getId(), writer)).isInstanceOf( - BadRequestException.class).hasMessage("게시글 투표 옵션이 게시글과 연관되어 있지 않습니다."); - } - - @Test - @DisplayName("게시글 작성자가 아니라면 예외를 던진다.") - void throwExceptionNotWriter() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Member reader = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - PostOption postOption = postOptionRepository.save( - PostOption.builder().post(post).sequence(1).content("치킨").build()); - - // when, then - assertThatThrownBy( - () -> postService.getVoteOptionStatistics(post.getId(), postOption.getId(), reader)).isInstanceOf( - BadRequestException.class).hasMessage("해당 게시글 작성자가 아닙니다."); - } - - @Test - @DisplayName("게시글 투표 옵션에 대한 투표 통계를 조회한다.") - void getVoteOptionStatistics() { - // given - Member female10 = memberRepository.save(FEMALE_10.get()); - Member male10 = memberRepository.save(MALE_10.get()); - Member male60 = memberRepository.save(MALE_60.get()); - Member female70 = memberRepository.save(FEMALE_70.get()); - Member female80 = memberRepository.save(FEMALE_80.get()); - Member writer = memberRepository.save(MALE_20.get()); - - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)).build()); - PostOption postOption = postOptionRepository.save( - PostOption.builder().post(post).sequence(1).content("치킨").build()); - - voteRepository.save(Vote.builder().member(female10).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(male10).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(male60).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(female70).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(female80).postOption(postOption).build()); - - // when - VoteOptionStatisticsResponse response = postService.getVoteOptionStatistics(post.getId(), - postOption.getId(), writer); - - // then - assertAll(() -> assertThat(response.totalVoteCount()).isEqualTo(5), - () -> assertThat(response.totalMaleCount()).isEqualTo(2), - () -> assertThat(response.totalFemaleCount()).isEqualTo(3), - () -> assertThat(response.ageGroup()).hasSize(7), - () -> assertThat(response.ageGroup().get(1).ageGroup()).isEqualTo("10대"), - () -> assertThat(response.ageGroup().get(1).voteCount()).isEqualTo(2), - () -> assertThat(response.ageGroup().get(1).maleCount()).isEqualTo(1), - () -> assertThat(response.ageGroup().get(1).femaleCount()).isEqualTo(1)); - } - - } - - @Test - @DisplayName("해당 게시글을 조기 마감 합니다") - void postClosedEarlyById() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_30.get()); - LocalDateTime oldDeadline = LocalDateTime.now().plusDays(2); - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(oldDeadline).build()); - - Post foundPost = postRepository.findById(post.getId()).get(); - - // when - postService.closePostEarlyById(post.getId(), writer); - - // then - assertAll(() -> assertThat(foundPost.getId()).isEqualTo(post.getId()), - () -> assertThat(foundPost.getDeadline()).isBefore(oldDeadline)); - } - - @Test - @DisplayName("해당 게시글을 조기 마감할 시, 작성자가 아니면 예외를 던진다.") - void throwExceptionNotWriterPostClosedEarly() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_30.get()); - LocalDateTime oldDeadline = LocalDateTime.of(2100, 7, 12, 0, 0); - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(oldDeadline).build()); - - Post foundPost = postRepository.findById(post.getId()).get(); - - // when, then - assertThatThrownBy( - () -> postService.closePostEarlyById(foundPost.getId(), MemberFixtures.MALE_30.get())).isInstanceOf( - BadRequestException.class).hasMessage("해당 게시글 작성자가 아닙니다."); - } - - @Test - @DisplayName("해당 게시글을 조기 마감할 시, 마감이 된 게시글이면 예외를 던진다.") - void throwExceptionDeadLinePostClosedEarly() { - // given - Member writer = memberRepository.save(MemberFixtures.MALE_30.get()); - LocalDateTime oldDeadline = LocalDateTime.of(2000, 7, 12, 0, 0); - Post post = postRepository.save( - Post.builder().writer(writer).postBody(PostBody.builder().title("title").content("content").build()) - .deadline(oldDeadline).build()); - - Post foundPost = postRepository.findById(post.getId()).get(); - - // when, then - assertThatThrownBy(() -> postService.closePostEarlyById(foundPost.getId(), writer)).isInstanceOf( - BadRequestException.class).hasMessage("게시글이 이미 마감되었습니다."); - } - - @Test - @DisplayName("정렬 유형 및 마감 유형별로 모든 게시물 가져온다") - void getAllPostBySortTypeAndClosingType() { - // given - Member writer = MALE_30.get(); - memberRepository.save(writer); - - Member memberToAllPostVote = MALE_20.get(); - memberRepository.save(memberToAllPostVote); - - Category ca1 = Category.builder().name("ca1").build(); - Category ca2 = Category.builder().name("ca2").build(); - Category ca3 = Category.builder().name("ca3").build(); - - categoryRepository.saveAll(List.of(ca1, ca2, ca3)); - - MockMultipartFile file1 = new MockMultipartFile("file1", "hello1.txt", "text/plain", - "Hello, World!11".getBytes()); - - MockMultipartFile file2 = new MockMultipartFile("file2", "hello2.txt", "text/plain", - "Hello, World!22".getBytes()); - - MockMultipartFile file3 = new MockMultipartFile("file3", "hello3.txt", "text/plain", - "Hello, World!33".getBytes()); - - for (int postSequence = 30; postSequence > 0; postSequence--) { - List options = new ArrayList<>() { - { - add(PostOptionCreateRequest.builder().content("option1").build()); - add(PostOptionCreateRequest.builder().content("option2").build()); - } - }; - - List optionImages = new ArrayList<>() { - { - add(file1); - add(file2); - } - }; - - List contentImages = new ArrayList<>() { - { - add(file3); - } - }; - - if (postSequence % 2 == 0) { - MockMultipartFile file4 = new MockMultipartFile("file4", "hello4.txt", "text/plain", - "Hello, World!44".getBytes()); - - optionImages.add(file4); - options.add(PostOptionCreateRequest.builder().content("option3").build()); - } - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(0L, 2L)) - .title("title" + postSequence).content("content" + postSequence).postOptions(options) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, contentImages, optionImages); - Post post = postRepository.findById(savedPostId).get(); - - List postOptions = post.getPostOptions().getPostOptions(); - Long postOptionId = postOptions.get(0).getId(); - voteService.vote(memberToAllPostVote, post.getId(), postOptionId); - - for (int voteCount = 0; voteCount <= postSequence; voteCount++) { - Member memberToVote = Member.builder().nickname("Abel" + postSequence + voteCount).gender(Gender.MALE) - .birthYear(2000).socialType(SocialType.KAKAO).socialId("Abel" + postSequence + voteCount) - .build(); - - memberRepository.save(memberToVote); - - PostOption perPostOption = postOptions.get(voteCount % postOptions.size()); - voteService.vote(memberToVote, savedPostId, perPostOption.getId()); - } - } - - entityManager.clear(); - - // when - List responses = postService.getAllPostBySortTypeAndClosingTypeAndCategoryId(0, - PostClosingType.PROGRESS, PostSortType.HOT, null, memberToAllPostVote); - - // then - PostResponse firstResponse = responses.get(0); - PostResponse secondResponse = responses.get(1); - assertAll(() -> assertThat(firstResponse.voteInfo().options()).hasSize(3), - () -> assertThat(firstResponse.voteInfo().totalVoteCount()).isEqualTo(32), - () -> assertThat(secondResponse.voteInfo().options()).hasSize(2), - () -> assertThat(secondResponse.voteInfo().totalVoteCount()).isEqualTo(31)); - } - - @Test - @DisplayName("비회원 게시글 목록 조회 시 마감된 게시글은 결과를 확인할 수 있고, 진행중인 게시글은 결과를 확인할 수 없다.") - void getPostsGuest() { - // given - List voters = new ArrayList<>(); - Member writer = memberTestPersister.builder().save(); - - for (int i = 0; i < 5; i++) { - voters.add(memberTestPersister.builder().save()); - } - - Post closedPost = postTestPersister.builder().writer(writer).deadline(LocalDateTime.of(2022, 12, 25, 0, 0)) - .save(); - - for (int j = 0; j < 2; j++) { - PostOption postOption = postOptionTestPersister.builder().sequence(j + 1).post(closedPost).save(); - for (int k = 0; k < 4; k++) { - voteTestPersister.builder().member(voters.get(k)).postOption(postOption).save(); - } - } - - Post notClosedPost = postTestPersister.builder().writer(writer).deadline(LocalDateTime.of(3022, 12, 25, 0, 0)) - .save(); - - for (int j = 0; j < 2; j++) { - PostOption postOption = postOptionTestPersister.builder().sequence(j + 1).post(notClosedPost).save(); - for (int k = 0; k < 4; k++) { - voteTestPersister.builder().member(voters.get(k)).postOption(postOption).save(); - } - } - - entityManager.flush(); - entityManager.clear(); - - // when - List result = postService.getPostsGuest(0, PostClosingType.ALL, PostSortType.LATEST, null); - - // then - assertAll(() -> assertThat(result).hasSize(2), - () -> assertThat(result.get(0).voteInfo().totalVoteCount()).isEqualTo(-1), - () -> assertThat(result.get(0).voteInfo().options().get(0).voteCount()).isEqualTo(-1), - () -> assertThat(result.get(0).voteInfo().options().get(1).voteCount()).isEqualTo(-1), - () -> assertThat(result.get(1).voteInfo().totalVoteCount()).isEqualTo(8), - () -> assertThat(result.get(1).voteInfo().options().get(0).voteCount()).isEqualTo(4), - () -> assertThat(result.get(1).voteInfo().options().get(1).voteCount()).isEqualTo(4)); - } - - @Test - @DisplayName("한 게시글의 상세를 조회할 시, 작성자면 투표 결과를 알 수 있다.") - void getPostByWriter() throws IOException { - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image1", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - LocalDateTime deadline = LocalDateTime.now().plusDays(3); - - PostOptionCreateRequest option1 = PostOptionCreateRequest.builder().content("option1").build(); - - PostOptionCreateRequest option2 = PostOptionCreateRequest.builder().content("option2").build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(option1, option2)).deadline(deadline).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(), List.of(file1, file2)); - - // when - PostDetailResponse response = postService.getPostById(savedPostId, writer); - - // then - List categories = response.categories(); - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - List options = voteDetailResponse.options(); - - assertAll(() -> assertThat(response.postId()).isEqualTo(savedPostId), - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), () -> assertThat(categories).hasSize(2), - () -> assertThat(categories.get(0).name()).isEqualTo("개발"), - () -> assertThat(writerResponse.id()).isEqualTo(writer.getId()), - () -> assertThat(writerResponse.nickname()).isEqualTo("user7"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isZero(), () -> assertThat(options).hasSize(2), - () -> assertThat(options.get(0).imageUrl()).contains("test1.png")); - } - - @Test - @DisplayName("한 게시글의 상세를 조회할 시, 해당 게시글의 투표자면 결과를 알 수 있다.") - void getPostByVoter() throws IOException { - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member voter = memberRepository.save(MemberFixtures.MALE_20.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image1", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - LocalDateTime deadline = LocalDateTime.now().plusDays(3); - - PostOptionCreateRequest option1 = PostOptionCreateRequest.builder().content("option1").build(); - - PostOptionCreateRequest option2 = PostOptionCreateRequest.builder().content("option2").build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(option1, option2)).deadline(deadline).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(), List.of(file1, file2)); - Post post = postRepository.findById(savedPostId).get(); - PostOptions postOptions = post.getPostOptions(); - PostOption postOption = postOptions.getPostOptions().get(0); - voteService.vote(voter, savedPostId, postOption.getId()); - - entityManager.clear(); - - // when - PostDetailResponse response = postService.getPostById(savedPostId, voter); - - // then - List categories = response.categories(); - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - List options = voteDetailResponse.options(); - - assertAll(() -> assertThat(response.postId()).isEqualTo(savedPostId), - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), () -> assertThat(categories).hasSize(2), - () -> assertThat(categories.get(0).name()).isEqualTo("개발"), - () -> assertThat(writerResponse.id()).isEqualTo(writer.getId()), - () -> assertThat(writerResponse.nickname()).isEqualTo("user10"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isOne(), () -> assertThat(options).hasSize(2), - () -> assertThat(options.get(0).imageUrl()).contains("test1.png")); - } - - @Test - @DisplayName("한 게시글의 상세를 조회할 시, 마감된 게시글이면 투표 결과를 알 수 있다.") - void getClosedPost() throws IOException { - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member member = memberRepository.save(MemberFixtures.FEMALE_10.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image1", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - LocalDateTime deadline = LocalDateTime.now(); - - PostOptionCreateRequest option1 = PostOptionCreateRequest.builder().content("option1").build(); - - PostOptionCreateRequest option2 = PostOptionCreateRequest.builder().content("option2").build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(option1, option2)).deadline(deadline).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(), List.of(file1, file2)); - - // when - PostDetailResponse response = postService.getPostById(savedPostId, member); - - // then - List categories = response.categories(); - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - List options = voteDetailResponse.options(); - - assertAll(() -> assertThat(response.postId()).isEqualTo(savedPostId), - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), () -> assertThat(categories).hasSize(2), - () -> assertThat(categories.get(0).name()).isEqualTo("개발"), - () -> assertThat(writerResponse.id()).isEqualTo(writer.getId()), - () -> assertThat(writerResponse.nickname()).isEqualTo("user7"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isZero(), () -> assertThat(options).hasSize(2), - () -> assertThat(options.get(0).imageUrl()).contains("test1.png")); - } - - @Test - @DisplayName("한 게시글의 상세를 조회할 시, 작성자, 투표자, 마감된 게시글이 전부 아니면 투표 결과를 알 수 없다.") - void getPostInvisibleResult() throws IOException { - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member member = memberRepository.save(MemberFixtures.FEMALE_10.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image1", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - LocalDateTime deadline = LocalDateTime.now().plusDays(3); - - PostOptionCreateRequest option1 = PostOptionCreateRequest.builder().content("option1").build(); - - PostOptionCreateRequest option2 = PostOptionCreateRequest.builder().content("option2").build(); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(option1, option2)).deadline(deadline).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(), List.of(file1, file2)); - - // when - PostDetailResponse response = postService.getPostById(savedPostId, member); - - // then - List categories = response.categories(); - WriterResponse writerResponse = response.writer(); - VoteDetailResponse voteDetailResponse = response.voteInfo(); - List options = voteDetailResponse.options(); - - assertAll(() -> assertThat(response.postId()).isEqualTo(savedPostId), - () -> assertThat(response.title()).isEqualTo("title"), - () -> assertThat(response.content()).isEqualTo("content"), () -> assertThat(categories).hasSize(2), - () -> assertThat(categories.get(0).name()).isEqualTo("개발"), - () -> assertThat(writerResponse.id()).isEqualTo(writer.getId()), - () -> assertThat(writerResponse.nickname()).isEqualTo("user7"), - () -> assertThat(voteDetailResponse.totalVoteCount()).isEqualTo(-1), - () -> assertThat(options).hasSize(2), - () -> assertThat(options.get(0).imageUrl()).contains("test1.png")); - } - - @Test - @DisplayName("존재하지 않은 게시글을 가져오려 할 시, 예외를 던진다.") - void throwExceptionNotFoundPost() { - // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - // when, then - assertThatThrownBy(() -> postService.getPostById(1L, member)).isInstanceOf(NotFoundException.class) - .hasMessage(PostExceptionType.POST_NOT_FOUND.getMessage()); - } - - @Test - void delete() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - final Post post = postRepository.findById(savedPostId).get(); - final List postOptions = post.getPostOptions().getPostOptions(); - - Member voter = MALE_30.get(); - memberRepository.save(voter); - PostOption perPostOption = postOptions.get(0); - voteService.vote(voter, savedPostId, perPostOption.getId()); - - CommentRegisterRequest commentRegisterRequest = new CommentRegisterRequest("hello"); - postCommentService.createComment(voter, post.getId(), commentRegisterRequest); - entityManager.flush(); - entityManager.clear(); - - final List postCategories = post.getPostCategories().getPostCategories(); - final PostBody postBody = post.getPostBody(); - final PostContentImage postContentImage = postBody.getPostContentImages().getContentImages().get(0); - final List votes = postOptions.stream().map(PostOption::getVotes).flatMap(Collection::stream).toList(); - final List comments = post.getComments(); - - // when, then - assertAll(() -> assertThatNoException().isThrownBy(() -> postService.delete(savedPostId)), - () -> assertThat(postCategoryRepository.findById(postCategories.get(0).getId())).isNotPresent(), - () -> assertThat(postContentImageRepository.findById(postContentImage.getId())).isNotPresent(), - () -> assertThat(postOptionRepository.findById(postOptions.get(0).getId())).isNotPresent(), - () -> assertThat(voteRepository.findById(votes.get(0).getId())).isNotPresent(), - () -> assertThat(commentRepository.findById(comments.get(0).getId())).isNotPresent()); - } - - @Test - @DisplayName("게시글을 삭제할 시, 투표가 20개 이상 진행된 게시글이면 예외를 던진다.") - void throwExceptionDeleteVoteOverTwenty() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder() - .categoryIds(List.of(category1.getId(), category2.getId())).title("title").content("content") - .postOptions(List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, member, List.of(file3), List.of(file1, file2)); - final Post post = postRepository.findById(savedPostId).get(); - - for (int voteCount = 0; voteCount < 20; voteCount++) { - Member memberToVote = Member.builder().nickname("Abel" + voteCount).gender(Gender.MALE).birthYear(2000) - .socialType(SocialType.KAKAO).socialId("Abel" + voteCount).build(); - - memberRepository.save(memberToVote); - - final List postOptions = post.getPostOptions().getPostOptions(); - PostOption perPostOption = postOptions.get(voteCount % postOptions.size()); - voteService.vote(memberToVote, savedPostId, perPostOption.getId()); - } - - entityManager.clear(); - - // when, then - assertThatThrownBy(() -> postService.delete(savedPostId)).isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.CANNOT_DELETE_BECAUSE_MORE_THAN_TWENTY_VOTES.getMessage()); - } - - @Test - @DisplayName("게시글을 수정한다") - void update() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(category1.getId())) - .title("title").content("content").postOptions( - List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - MockMultipartFile file4 = new MockMultipartFile("image4", "test4.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file5 = new MockMultipartFile("image5", "test5.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file6 = new MockMultipartFile("image6", "test6.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder().categoryIds(List.of(category2.getId())) - .title("title2").content("content2").postOptions( - List.of(PostOptionUpdateRequest.builder().content("option3").build(), - PostOptionUpdateRequest.builder().content("option4").build())) - .deadline(LocalDateTime.now().plusDays(1)).build(); - - // when - postService.update(savedPostId, postUpdateRequest, writer, List.of(file4), List.of(file5, file6)); - - // then - final PostDetailResponse postDetailResponse = postService.getPostById(savedPostId, writer); - final List options = postDetailResponse.voteInfo().options(); - final List categories = postDetailResponse.categories(); - assertAll(() -> assertThat(postDetailResponse.title()).isEqualTo("title2"), - () -> assertThat(postDetailResponse.content()).isEqualTo("content2"), - () -> assertThat(postDetailResponse.imageUrl()).contains("test4.png"), - () -> assertThat(options.get(0).content()).isEqualTo("option3"), - () -> assertThat(options.get(1).content()).isEqualTo("option4"), - () -> assertThat(options.get(0).imageUrl()).contains("test5.png"), - () -> assertThat(options.get(1).imageUrl()).contains("test6.png"), - () -> assertThat(categories.get(0).name()).isEqualTo("음식")); - } - - @Test - @DisplayName("게시글 수정 시, 작성자가 아니면 예외를 던진다.") - void throwExceptionUpdateNotWriter() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Member member = memberRepository.save(MemberFixtures.MALE_30.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(category1.getId())) - .title("title").content("content").postOptions( - List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - MockMultipartFile file4 = new MockMultipartFile("image4", "test4.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file5 = new MockMultipartFile("image5", "test5.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file6 = new MockMultipartFile("image6", "test6.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder().categoryIds(List.of(category2.getId())) - .title("title2").content("content2").postOptions( - List.of(PostOptionUpdateRequest.builder().content("option3").build(), - PostOptionUpdateRequest.builder().content("option4").build())) - .deadline(LocalDateTime.now().plusDays(1)).build(); - - // when, then - assertThatThrownBy(() -> postService.update(savedPostId, postUpdateRequest, member, List.of(file4), - List.of(file5, file6))).isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.NOT_WRITER.getMessage()); - } - - @Test - @DisplayName("게시글 수정 시, 마감된 게시글이면 예외를 던진다.") - void throwExceptionUpdateClosedPost() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(category1.getId())) - .title("title").content("content").postOptions( - List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now()).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - MockMultipartFile file4 = new MockMultipartFile("image4", "test4.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file5 = new MockMultipartFile("image5", "test5.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file6 = new MockMultipartFile("image6", "test6.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder().categoryIds(List.of(category2.getId())) - .title("title2").content("content2").postOptions( - List.of(PostOptionUpdateRequest.builder().content("option3").build(), - PostOptionUpdateRequest.builder().content("option4").build())) - .deadline(LocalDateTime.now().plusDays(1)).build(); - - // when, then - assertThatThrownBy(() -> postService.update(savedPostId, postUpdateRequest, writer, List.of(file4), - List.of(file5, file6))).isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.POST_CLOSED.getMessage()); - } - - @Test - @DisplayName("게시글 수정 시, 수정할 마감 기한이 생성 날짜보다 3일 초과면 예외를 던진다.") - void throwExceptionUpdateDeadlineOver() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(category1.getId())) - .title("title").content("content").postOptions( - List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(3)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - MockMultipartFile file4 = new MockMultipartFile("image4", "test4.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file5 = new MockMultipartFile("image5", "test5.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file6 = new MockMultipartFile("image6", "test6.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder().categoryIds(List.of(category2.getId())) - .title("title2").content("content2").postOptions( - List.of(PostOptionUpdateRequest.builder().content("option3").build(), - PostOptionUpdateRequest.builder().content("option4").build())) - .deadline(LocalDateTime.now().plusDays(4)).build(); - - // when, then - assertThatThrownBy(() -> postService.update(savedPostId, postUpdateRequest, writer, List.of(file4), - List.of(file5, file6))).isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.DEADLINE_EXCEED_THREE_DAYS.getMessage()); - } - - @Test - @DisplayName("게시글을 수정할 시, 해당 게시글이 투표가 되어있으면 예외를 던진다.") - void throwExceptionUpdateVotingProgress() throws IOException { - // given - Category category1 = categoryRepository.save(CategoryFixtures.DEVELOP.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - MockMultipartFile file1 = new MockMultipartFile("image1", "test1.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file2 = new MockMultipartFile("image2", "test2.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file3 = new MockMultipartFile("image3", "test3.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostCreateRequest postCreateRequest = PostCreateRequest.builder().categoryIds(List.of(category1.getId())) - .title("title").content("content").postOptions( - List.of(PostOptionCreateRequest.builder().content("option1").build(), - PostOptionCreateRequest.builder().content("option2").build())) - .deadline(LocalDateTime.now().plusDays(2)).build(); - - Long savedPostId = postService.save(postCreateRequest, writer, List.of(file3), List.of(file1, file2)); - - Member memberToVote = Member.builder().nickname("Abel").gender(Gender.MALE).birthYear(2000) - .socialType(SocialType.KAKAO).socialId("Abel").build(); - - memberRepository.save(memberToVote); - - final Post post = postRepository.findById(savedPostId).get(); - final List postOptions = post.getPostOptions().getPostOptions(); - PostOption perPostOption = postOptions.get(0); - voteService.vote(memberToVote, savedPostId, perPostOption.getId()); - entityManager.clear(); - - Category category2 = categoryRepository.save(CategoryFixtures.FOOD.get()); - MockMultipartFile file4 = new MockMultipartFile("image4", "test4.png", "image/png", - new FileInputStream("src/test/resources/images/testImage1.PNG")); - MockMultipartFile file5 = new MockMultipartFile("image5", "test5.png", "image/png", - new FileInputStream("src/test/resources/images/testImage2.PNG")); - - MockMultipartFile file6 = new MockMultipartFile("image6", "test6.png", "image/png", - new FileInputStream("src/test/resources/images/testImage3.PNG")); - - PostUpdateRequest postUpdateRequest = PostUpdateRequest.builder().categoryIds(List.of(category2.getId())) - .title("title2").content("content2").postOptions( - List.of(PostOptionUpdateRequest.builder().content("option3").build(), - PostOptionUpdateRequest.builder().content("option4").build())) - .deadline(LocalDateTime.now().plusDays(1)).build(); - - // when, then - assertThatThrownBy(() -> postService.update(savedPostId, postUpdateRequest, writer, List.of(file4), - List.of(file5, file6))).isInstanceOf(BadRequestException.class) - .hasMessage(PostExceptionType.VOTING_PROGRESS_NOT_EDITABLE.getMessage()); - } - - @Test - @DisplayName("회원 본인이 작성한 게시글 목록을 가져온다.") - void getPostsByWriter() { - // given - Member writer = memberTestPersister.builder().save(); - Post post = postTestPersister.builder().writer(writer).save(); - PostOption postOption = postOptionTestPersister.builder().post(post).sequence(1).save(); - PostOption postOption1 = postOptionTestPersister.builder().post(post).sequence(2).save(); - voteTestPersister.builder().postOption(postOption).save(); - voteTestPersister.builder().postOption(postOption).save(); - voteTestPersister.builder().postOption(postOption1).save(); - - entityManager.flush(); - entityManager.clear(); - - // when - List responses = postService.getPostsByWriter(0, PostClosingType.ALL, PostSortType.LATEST, null, - writer); - - // then - assertAll(() -> assertThat(responses).hasSize(1), - () -> assertThat(responses.get(0).postId()).isEqualTo(post.getId()), - () -> assertThat(responses.get(0).writer().id()).isEqualTo(writer.getId()), - () -> assertThat(responses.get(0).voteInfo().totalVoteCount()).isEqualTo(3L)); - } - - @Test - @DisplayName("회원으로 키워드를 통해 게시글 목록을 검색한다.") - void getPostsByKeyword() { - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - Member member1 = memberRepository.save(MemberFixtures.MALE_30.get()); - - Post openPost = postTestPersister.builder().postBody(PostBody.builder().title("제목").content("키워요").build()) - .deadline(LocalDateTime.now().plusDays(3L)).save(); - Post openPost1 = postTestPersister.builder().postBody(PostBody.builder().title("키워드").content("안녕").build()) - .deadline(LocalDateTime.now().plusDays(3L)).save(); - - PostOption postOption = postOptionTestPersister.builder().post(openPost).save(); - PostOption postOption1 = postOptionTestPersister.builder().post(openPost1).save(); - voteTestPersister.builder().member(member).postOption(postOption).save(); - voteTestPersister.builder().member(member1).postOption(postOption1).save(); - - entityManager.flush(); - entityManager.clear(); - - // when - List responses = postService.searchPostsWithKeyword("키워", 0, PostClosingType.ALL, - PostSortType.LATEST, null, member); - - // then - assertAll(() -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).postId()).isEqualTo(openPost1.getId()), - () -> assertThat(responses.get(1).postId()).isEqualTo(openPost.getId()), - () -> assertThat(hasKeywordInPostResponse(responses.get(0), "키워")).isTrue(), - () -> assertThat(hasKeywordInPostResponse(responses.get(1), "키워")).isTrue(), - () -> assertThat(responses.get(0).voteInfo().totalVoteCount()).isEqualTo(-1L), - () -> assertThat(responses.get(1).voteInfo().totalVoteCount()).isEqualTo(1L)); - } - - private boolean hasKeywordInPostResponse(PostResponse postResponse, String keyword) { - return postResponse.title().contains(keyword) || postResponse.content().contains(keyword); - } - - @Test - @DisplayName("비회원으로 키워드를 통해 게시글 목록을 검색한다.") - void getPostsByKeywordForGuest() { - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post closedPost = postTestPersister.builder().postBody(PostBody.builder().title("제목").content("키워요").build()) - .deadline(LocalDateTime.now().minusDays(3L)).save(); - Post openPost1 = postTestPersister.builder().postBody(PostBody.builder().title("키워드").content("안녕").build()) - .deadline(LocalDateTime.now().plusDays(3L)).save(); - - PostOption postOption = postOptionTestPersister.builder().post(closedPost).save(); - PostOption postOption1 = postOptionTestPersister.builder().post(openPost1).save(); - voteTestPersister.builder().member(member).postOption(postOption).save(); - voteTestPersister.builder().member(member).postOption(postOption1).save(); - - entityManager.flush(); - entityManager.clear(); - - // when - List responses = postService.searchPostsWithKeywordForGuest("키워", 0, PostClosingType.ALL, - PostSortType.LATEST, null); - - // then - assertAll(() -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).postId()).isEqualTo(openPost1.getId()), - () -> assertThat(responses.get(1).postId()).isEqualTo(closedPost.getId()), - () -> assertThat(hasKeywordInPostResponse(responses.get(0), "키워")).isTrue(), - () -> assertThat(hasKeywordInPostResponse(responses.get(1), "키워")).isTrue(), - () -> assertThat(responses.get(0).voteInfo().totalVoteCount()).isEqualTo(-1L), - () -> assertThat(responses.get(1).voteInfo().totalVoteCount()).isEqualTo(1L)); - } - - - @Nested - @DisplayName("상위 10개 인기 게시물을 조회한다.") - class Ranking { - - @Test - @DisplayName("중복 순위가 있는 경우") - void getRanking() { - // given - List posts = new ArrayList<>(); - List postOptions = new ArrayList<>(); - - for (int i = 0; i < 11; i++) { - Post post = postTestPersister.builder().save(); - PostOption postOption = postOptionTestPersister.builder().post(post).save(); - posts.add(post); - postOptions.add(postOption); - } - - for (int i = 0; i < 10; i++) { - for (int j = 0; j < i + 1; j++) { - voteTestPersister.builder().postOption(postOptions.get(i)).save(); - } - } - - voteTestPersister.builder().postOption(postOptions.get(10)).save(); - voteTestPersister.builder().postOption(postOptions.get(10)).save(); - voteTestPersister.builder().postOption(postOptions.get(10)).save(); - voteTestPersister.builder().postOption(postOptions.get(7)).save(); - - /* - index |0| |1| |2| |3| |4| |5| |6| |7| |8| |9| |10| - voteCount |1| |2| |3| |4| |5| |6| |7| |9| |9| |10| |3| - ranking |11| |10| |8| |7| |6| |5| |4| |2| |2| |1| |8| - */ - - entityManager.clear(); - entityManager.flush(); - - // when - List rankings = postService.getRanking(); - - // then - assertAll( - () -> assertThat(rankings).hasSize(10), - () -> assertThat(rankings.get(0).ranking()).isEqualTo(1), - () -> assertThat(rankings.get(1).ranking()).isEqualTo(2), - () -> assertThat(rankings.get(2).ranking()).isEqualTo(2), - () -> assertThat(rankings.get(3).ranking()).isEqualTo(4), - () -> assertThat(rankings.get(4).ranking()).isEqualTo(5), - () -> assertThat(rankings.get(5).ranking()).isEqualTo(6), - () -> assertThat(rankings.get(6).ranking()).isEqualTo(7), - () -> assertThat(rankings.get(7).ranking()).isEqualTo(8), - () -> assertThat(rankings.get(8).ranking()).isEqualTo(8), - () -> assertThat(rankings.get(9).ranking()).isEqualTo(10), - - () -> assertThat(rankings.get(0).postSummaryResponse().id()).isEqualTo(posts.get(9).getId()), - () -> assertThat(rankings.get(3).postSummaryResponse().id()).isEqualTo(posts.get(6).getId()), - () -> assertThat(rankings.get(4).postSummaryResponse().id()).isEqualTo(posts.get(5).getId()), - () -> assertThat(rankings.get(5).postSummaryResponse().id()).isEqualTo(posts.get(4).getId()), - () -> assertThat(rankings.get(6).postSummaryResponse().id()).isEqualTo(posts.get(3).getId()), - () -> assertThat(rankings.get(9).postSummaryResponse().id()).isEqualTo(posts.get(1).getId()), - () -> assertThat( - List.of(rankings.get(7).postSummaryResponse().id(), - rankings.get(8).postSummaryResponse().id()) - .containsAll(List.of(posts.get(10).getId(), posts.get(2).getId()))), - () -> assertThat( - List.of(rankings.get(1).postSummaryResponse().id(), - rankings.get(2).postSummaryResponse().id()) - .containsAll(List.of(posts.get(7).getId(), - posts.get(8).getId()))) - ); - } - - @Test - @DisplayName("중복 순위가 없는 경우") - void getRanking1() { - // given - List posts = new ArrayList<>(); - List postOptions = new ArrayList<>(); - - for (int i = 0; i < 10; i++) { - Post post = postTestPersister.builder().save(); - PostOption postOption = postOptionTestPersister.builder().post(post).save(); - posts.add(post); - postOptions.add(postOption); - } - - for (int i = 0; i < 10; i++) { - for (int j = 0; j < i + 1; j++) { - voteTestPersister.builder().postOption(postOptions.get(i)).save(); - } - } - - /* - index |0| |1| |2| |3| |4| |5| |6| |7| |8| |9| - voteCount |1| |2| |3| |4| |5| |6| |7| |8| |9| |10| - ranking |10| |9| |8| |7| |6| |5| |4| |3| |2| |1| - */ - - entityManager.clear(); - entityManager.flush(); - - // when - List rankings = postService.getRanking(); - - // then - assertAll( - () -> assertThat(rankings).hasSize(10), - () -> assertThat(rankings.get(0).ranking()).isEqualTo(1), - () -> assertThat(rankings.get(1).ranking()).isEqualTo(2), - () -> assertThat(rankings.get(2).ranking()).isEqualTo(3), - () -> assertThat(rankings.get(3).ranking()).isEqualTo(4), - () -> assertThat(rankings.get(4).ranking()).isEqualTo(5), - () -> assertThat(rankings.get(5).ranking()).isEqualTo(6), - () -> assertThat(rankings.get(6).ranking()).isEqualTo(7), - () -> assertThat(rankings.get(7).ranking()).isEqualTo(8), - () -> assertThat(rankings.get(8).ranking()).isEqualTo(9), - () -> assertThat(rankings.get(9).ranking()).isEqualTo(10), - - () -> assertThat(rankings.get(0).postSummaryResponse().id()).isEqualTo(posts.get(9).getId()), - () -> assertThat(rankings.get(1).postSummaryResponse().id()).isEqualTo(posts.get(8).getId()), - () -> assertThat(rankings.get(2).postSummaryResponse().id()).isEqualTo(posts.get(7).getId()), - () -> assertThat(rankings.get(3).postSummaryResponse().id()).isEqualTo(posts.get(6).getId()), - () -> assertThat(rankings.get(4).postSummaryResponse().id()).isEqualTo(posts.get(5).getId()), - () -> assertThat(rankings.get(5).postSummaryResponse().id()).isEqualTo(posts.get(4).getId()), - () -> assertThat(rankings.get(6).postSummaryResponse().id()).isEqualTo(posts.get(3).getId()), - () -> assertThat(rankings.get(7).postSummaryResponse().id()).isEqualTo(posts.get(2).getId()), - () -> assertThat(rankings.get(8).postSummaryResponse().id()).isEqualTo(posts.get(1).getId()), - () -> assertThat(rankings.get(9).postSummaryResponse().id()).isEqualTo(posts.get(0).getId()) - ); - } - - } - -} diff --git a/backend/src/test/java/com/votogether/domain/ranking/service/RankingServiceTest.java b/backend/src/test/java/com/votogether/domain/ranking/service/RankingServiceTest.java index cfeea9c15..99faa0bcb 100644 --- a/backend/src/test/java/com/votogether/domain/ranking/service/RankingServiceTest.java +++ b/backend/src/test/java/com/votogether/domain/ranking/service/RankingServiceTest.java @@ -5,34 +5,17 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.ranking.dto.response.RankingResponse; -import com.votogether.test.annotation.ServiceTest; -import com.votogether.test.persister.MemberTestPersister; -import com.votogether.test.persister.PostOptionTestPersister; -import com.votogether.test.persister.PostTestPersister; -import com.votogether.test.persister.VoteTestPersister; +import com.votogether.test.ServiceTest; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -@ServiceTest -class RankingServiceTest { +class RankingServiceTest extends ServiceTest { @Autowired RankingService rankingService; - @Autowired - MemberTestPersister memberTestPersister; - - @Autowired - PostTestPersister postTestPersister; - - @Autowired - PostOptionTestPersister postOptionTestPersister; - - @Autowired - VoteTestPersister voteTestPersister; - @Test @DisplayName("회원의 열정 랭킹을 조회한다.") void getMemberPassionRanking() { @@ -43,10 +26,10 @@ void getMemberPassionRanking() { Member memberD = memberTestPersister.builder().save(); Member memberE = memberTestPersister.builder().save(); - postTestPersister.builder().writer(memberA).save(); - postTestPersister.builder().writer(memberB).save(); - postTestPersister.builder().writer(memberC).save(); - postTestPersister.builder().writer(memberD).save(); + postTestPersister.postBuilder().writer(memberA).save(); + postTestPersister.postBuilder().writer(memberB).save(); + postTestPersister.postBuilder().writer(memberC).save(); + postTestPersister.postBuilder().writer(memberD).save(); voteTestPersister.builder().member(memberA).save(); voteTestPersister.builder().member(memberC).save(); @@ -81,11 +64,11 @@ void getPassionRankingTop10() { memberTestPersister.builder().save(); memberTestPersister.builder().save(); - postTestPersister.builder().writer(memberA).save(); - postTestPersister.builder().writer(memberB).save(); + postTestPersister.postBuilder().writer(memberA).save(); + postTestPersister.postBuilder().writer(memberB).save(); // when - final List rankings = rankingService.getPassionRanking(); + List rankings = rankingService.getPassionRanking(); // then assertAll( diff --git a/backend/src/test/java/com/votogether/domain/report/controller/ReportControllerTest.java b/backend/src/test/java/com/votogether/domain/report/controller/ReportCommandControllerTest.java similarity index 87% rename from backend/src/test/java/com/votogether/domain/report/controller/ReportControllerTest.java rename to backend/src/test/java/com/votogether/domain/report/controller/ReportCommandControllerTest.java index 699f90f96..d66dd0d68 100644 --- a/backend/src/test/java/com/votogether/domain/report/controller/ReportControllerTest.java +++ b/backend/src/test/java/com/votogether/domain/report/controller/ReportCommandControllerTest.java @@ -11,7 +11,7 @@ import com.votogether.domain.member.service.MemberService; import com.votogether.domain.report.dto.request.ReportRequest; import com.votogether.domain.report.entity.vo.ReportType; -import com.votogether.domain.report.service.ReportService; +import com.votogether.domain.report.service.ReportCommandService; import com.votogether.global.jwt.TokenPayload; import com.votogether.global.jwt.TokenProcessor; import io.restassured.http.ContentType; @@ -29,11 +29,11 @@ import org.springframework.http.HttpStatus; import org.springframework.web.context.WebApplicationContext; -@WebMvcTest(ReportController.class) -class ReportControllerTest { +@WebMvcTest(ReportCommandCommandController.class) +class ReportCommandControllerTest { @MockBean - ReportService reportService; + ReportCommandService reportCommandService; @MockBean TokenProcessor tokenProcessor; @@ -43,7 +43,7 @@ class ReportControllerTest { @BeforeEach void setUp(final WebApplicationContext webApplicationContext) { - RestAssuredMockMvc.standaloneSetup(new ReportController(reportService)); + RestAssuredMockMvc.standaloneSetup(new ReportCommandCommandController(reportCommandService)); RestAssuredMockMvc.webAppContextSetup(webApplicationContext); } @@ -69,7 +69,7 @@ void reportPost() throws Exception { given(memberService.findById(anyLong())).willReturn(member); ReportRequest request = new ReportRequest(ReportType.POST, 1L, "불건전한 게시글"); - willDoNothing().given(reportService).report(member, request); + willDoNothing().given(reportCommandService).report(member, request); // when, then RestAssuredMockMvc @@ -79,7 +79,8 @@ void reportPost() throws Exception { .body(request) .when().post("/report") .then().log().all() - .statusCode(HttpStatus.OK.value()); + .assertThat() + .status(HttpStatus.OK); } @Test @@ -100,7 +101,7 @@ void reportComment() throws Exception { given(memberService.findById(anyLong())).willReturn(member); ReportRequest request = new ReportRequest(ReportType.COMMENT, 1L, "불건전한 댓글"); - willDoNothing().given(reportService).report(member, request); + willDoNothing().given(reportCommandService).report(member, request); // when, then RestAssuredMockMvc @@ -110,7 +111,8 @@ void reportComment() throws Exception { .body(request) .when().post("/report") .then().log().all() - .statusCode(HttpStatus.OK.value()); + .assertThat() + .status(HttpStatus.OK); } @Test @@ -131,7 +133,7 @@ void reportNickname() throws Exception { given(memberService.findById(anyLong())).willReturn(member); ReportRequest request = new ReportRequest(ReportType.NICKNAME, 1L, "불건전한 닉네임"); - willDoNothing().given(reportService).report(member, request); + willDoNothing().given(reportCommandService).report(member, request); // when, then RestAssuredMockMvc @@ -141,7 +143,8 @@ void reportNickname() throws Exception { .body(request) .when().post("/report") .then().log().all() - .statusCode(HttpStatus.OK.value()); + .assertThat() + .status(HttpStatus.OK); } } @@ -165,7 +168,7 @@ void report(Long id) throws Exception { given(memberService.findById(anyLong())).willReturn(member); ReportRequest request = new ReportRequest(ReportType.COMMENT, id, "불건전한 게시글"); - willDoNothing().given(reportService).report(member, request); + willDoNothing().given(reportCommandService).report(member, request); // when, then RestAssuredMockMvc @@ -175,7 +178,8 @@ void report(Long id) throws Exception { .body(request) .when().post("/report") .then().log().all() - .statusCode(HttpStatus.BAD_REQUEST.value()); + .assertThat() + .status(HttpStatus.BAD_REQUEST); } @ParameterizedTest @@ -197,7 +201,7 @@ void reportBadRequest(String reason) throws Exception { given(memberService.findById(anyLong())).willReturn(member); ReportRequest request = new ReportRequest(ReportType.POST, 1L, reason); - willDoNothing().given(reportService).report(member, request); + willDoNothing().given(reportCommandService).report(member, request); // when, then RestAssuredMockMvc @@ -207,7 +211,8 @@ void reportBadRequest(String reason) throws Exception { .body(request) .when().post("/report") .then().log().all() - .statusCode(HttpStatus.BAD_REQUEST.value()); + .assertThat() + .status(HttpStatus.BAD_REQUEST); } } diff --git a/backend/src/test/java/com/votogether/domain/report/repository/ReportRepositoryTest.java b/backend/src/test/java/com/votogether/domain/report/repository/ReportRepositoryTest.java index 2c1422730..48c88ce5c 100644 --- a/backend/src/test/java/com/votogether/domain/report/repository/ReportRepositoryTest.java +++ b/backend/src/test/java/com/votogether/domain/report/repository/ReportRepositoryTest.java @@ -1,65 +1,32 @@ package com.votogether.domain.report.repository; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.repository.MemberRepository; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.repository.PostRepository; import com.votogether.domain.report.entity.Report; import com.votogether.domain.report.entity.vo.ReportType; -import com.votogether.test.annotation.RepositoryTest; -import com.votogether.test.fixtures.MemberFixtures; -import java.time.LocalDateTime; +import com.votogether.test.RepositoryTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -@RepositoryTest -class ReportRepositoryTest { +class ReportRepositoryTest extends RepositoryTest { @Autowired ReportRepository reportRepository; - @Autowired - MemberRepository memberRepository; - - @Autowired - PostRepository postRepository; - @Test @DisplayName("회원, 신고타입, 대상ID를 통해서 신고 횟수를 반환한다.") void countByMemberAndReportTypeAndTargetId() { // given - Member member = MemberFixtures.MALE_20.get(); - ReportType reportType = ReportType.POST; - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(member) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - memberRepository.save(member); - postRepository.save(post); - - Report report = Report.builder() - .member(member) - .reportType(reportType) - .targetId(post.getId()) - .reason("불건전한 게시글") - .build(); - reportRepository.save(report); + memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + reportTestPersister.builder().reportType(ReportType.POST).targetId(post.getId()).save(); // when - int reportCount = reportRepository.countByReportTypeAndTargetId(reportType, post.getId()); + int reportCount = reportRepository.countByReportTypeAndTargetId(ReportType.POST, post.getId()); // then assertThat(reportCount).isEqualTo(1); @@ -69,29 +36,9 @@ void countByMemberAndReportTypeAndTargetId() { @DisplayName("회원, 신고유형, 신고대상ID를 통해 해당 신고정보를 반환한다.") void findByMemberAndReportTypeAndTargetId() { // given - Member member = MemberFixtures.FEMALE_30.get(); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(member) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - memberRepository.save(member); - postRepository.save(post); - - Report report = Report.builder() - .targetId(post.getId()) - .reportType(ReportType.POST) - .member(member) - .reason("불건전한 게시글") - .build(); - reportRepository.save(report); + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + reportTestPersister.builder().member(member).reportType(ReportType.POST).targetId(post.getId()).save(); // when Report actualReport = reportRepository.findByMemberAndReportTypeAndTargetId( @@ -101,11 +48,28 @@ void findByMemberAndReportTypeAndTargetId() { ).get(); // then - assertAll( - () -> assertThat(actualReport.getTargetId()).isEqualTo(post.getId()), - () -> assertThat(actualReport.getMember()).isEqualTo(member), - () -> assertThat(actualReport.getReportType()).isEqualTo(ReportType.POST) - ); + assertSoftly(softly -> { + softly.assertThat(actualReport.getTargetId()).isEqualTo(post.getId()); + softly.assertThat(actualReport.getMember()).isEqualTo(member); + softly.assertThat(actualReport.getReportType()).isEqualTo(ReportType.POST); + }); + } + + @Test + @DisplayName("신고 유형, 신고대상ID를 통해 모든 신고 정보를 삭제한다.") + void deleteAllWithReportTypeAndTargetIdInBatch() { + // given + Member reporterA = memberTestPersister.builder().save(); + Member reporterB = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + reportTestPersister.builder().member(reporterA).reportType(ReportType.POST).targetId(post.getId()).save(); + reportTestPersister.builder().member(reporterB).reportType(ReportType.POST).targetId(post.getId()).save(); + + // when + reportRepository.deleteAllWithReportTypeAndTargetIdInBatch(ReportType.POST, post.getId()); + + // then + assertThat(reportRepository.findAll()).isEmpty(); } } diff --git a/backend/src/test/java/com/votogether/domain/report/service/ReportCommandServiceTest.java b/backend/src/test/java/com/votogether/domain/report/service/ReportCommandServiceTest.java new file mode 100644 index 000000000..851d33bbd --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/report/service/ReportCommandServiceTest.java @@ -0,0 +1,322 @@ +package com.votogether.domain.report.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostCommentService; +import com.votogether.domain.post.service.PostGuestService; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.test.ServiceTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ReportCommandServiceTest extends ServiceTest { + + @Autowired + ReportCommandService reportCommandService; + + @Autowired + PostGuestService postGuestService; + + @Autowired + PostCommentService postCommentService; + + @Nested + @DisplayName("게시글 신고기능은") + class ReportPost { + + @Test + @DisplayName("정상적으로 동작한다.") + void reportPost() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertDoesNotThrow(() -> reportCommandService.report(reporter, request)); + } + + @Test + @DisplayName("없는 투표글을 신고하는 경우 예외가 발생한다.") + void reportNonExistPostThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, -1L, "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(writer, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("자신의 투표글을 신고하는 경우 예외가 발생한다.") + void reportOwnPostThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(writer).save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(writer, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("본인 게시글은 신고할 수 없습니다."); + } + + @Test + @DisplayName("블라인드 처리된 투표글을 신고하는 경우 예외가 발생한다.") + void reportHiddenPost() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + post.blind(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 투표글을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when + reportCommandService.report(reporter, request); + + // then + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 글에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("투표글 신고가 5회가 되면 블라인드 처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberTestPersister.builder().save(); + Member reporter2 = memberTestPersister.builder().save(); + Member reporter3 = memberTestPersister.builder().save(); + Member reporter4 = memberTestPersister.builder().save(); + Member reporter5 = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when + reportCommandService.report(reporter1, request); + reportCommandService.report(reporter2, request); + reportCommandService.report(reporter3, request); + reportCommandService.report(reporter4, request); + reportCommandService.report(reporter5, request); + + // then + final List responses = postGuestService.getPosts( + 0, + PostClosingType.ALL, + PostSortType.HOT, + null + ); + assertAll( + () -> assertThat(post.isHidden()).isTrue(), + () -> assertThat(responses).isEmpty() + ); + } + + } + + @Nested + @DisplayName("댓글 신고기능은") + class ReportComment { + + @Test + @DisplayName("정상적으로 동작한다.") + void reportComment() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 게시글"); + + // when, then + assertDoesNotThrow(() -> reportCommandService.report(reporter, request)); + } + + @Test + @DisplayName("없는 댓글을 신고하는 경우 예외가 발생한다.") + void reportNonExistCommentThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, -1L, "불건전한 댓글"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(writer, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("댓글이 존재하지 않습니다."); + } + + @Test + @DisplayName("자신의 댓글을 신고하는 경우 예외가 발생한다.") + void reportOwnCommentThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).writer(writer).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(writer, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("본인 댓글은 신고할 수 없습니다."); + } + + @Test + @DisplayName("블라인드 처리된 댓글을 신고하는 경우 예외가 발생한다.") + void reportHiddenComment() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + comment.blind(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when, then + comment.blind(); + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 댓글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 댓글을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when + reportCommandService.report(reporter, request); + + // then + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 댓글에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("댓글 신고가 5회가 되면 블라인드 처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberTestPersister.builder().save(); + Member reporter2 = memberTestPersister.builder().save(); + Member reporter3 = memberTestPersister.builder().save(); + Member reporter4 = memberTestPersister.builder().save(); + Member reporter5 = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when + reportCommandService.report(reporter1, request); + reportCommandService.report(reporter2, request); + reportCommandService.report(reporter3, request); + reportCommandService.report(reporter4, request); + reportCommandService.report(reporter5, request); + + // then + assertAll( + () -> assertThat(comment.isHidden()).isTrue(), + () -> assertThat(postCommentService.getComments(post.getId())).isEmpty() + ); + } + + } + + @Nested + @DisplayName("닉네임 신고기능은") + class ReportNickname { + + @Test + @DisplayName("정상적으로 동작한다.") + void reportNickname() { + // given + Member reporter = memberTestPersister.builder().save(); + Member reported = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when, then + assertDoesNotThrow(() -> reportCommandService.report(reporter, request)); + } + + @Test + @DisplayName("자신의 닉네임을 신고하는 경우 예외가 발생한다.") + void reportOwnNicknameThrowsException() { + // given + Member reporter = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reporter.getId(), "불건전한 닉네임"); + + // when, then + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("자신의 닉네임은 신고할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 다른 회원의 닉네임을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberTestPersister.builder().save(); + Member reported = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when + reportCommandService.report(reporter, request); + + // then + assertThatThrownBy(() -> reportCommandService.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 닉네임에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("닉네임 신고가 3회가 되면 닉네임이 자동변경처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberTestPersister.builder().save(); + Member reporter2 = memberTestPersister.builder().save(); + Member reporter3 = memberTestPersister.builder().save(); + Member reported = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when + reportCommandService.report(reporter1, request); + reportCommandService.report(reporter2, request); + reportCommandService.report(reporter3, request); + + // then + assertThat(reported.getNickname()).contains("Pause1"); + } + } + +} diff --git a/backend/src/test/java/com/votogether/domain/report/service/ReportServiceTest.java b/backend/src/test/java/com/votogether/domain/report/service/ReportServiceTest.java deleted file mode 100644 index 74374aed9..000000000 --- a/backend/src/test/java/com/votogether/domain/report/service/ReportServiceTest.java +++ /dev/null @@ -1,517 +0,0 @@ -package com.votogether.domain.report.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import com.votogether.domain.member.entity.Member; -import com.votogether.domain.member.repository.MemberRepository; -import com.votogether.domain.post.dto.response.post.PostResponse; -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; -import com.votogether.domain.post.entity.comment.Comment; -import com.votogether.domain.post.entity.vo.PostClosingType; -import com.votogether.domain.post.entity.vo.PostSortType; -import com.votogether.domain.post.repository.CommentRepository; -import com.votogether.domain.post.repository.PostRepository; -import com.votogether.domain.post.service.PostCommentService; -import com.votogether.domain.post.service.PostService; -import com.votogether.domain.report.dto.request.ReportRequest; -import com.votogether.domain.report.entity.vo.ReportType; -import com.votogether.domain.report.repository.ReportRepository; -import com.votogether.global.exception.BadRequestException; -import com.votogether.global.exception.NotFoundException; -import com.votogether.test.annotation.ServiceTest; -import com.votogether.test.fixtures.MemberFixtures; -import java.time.LocalDateTime; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@ServiceTest -class ReportServiceTest { - - @Autowired - ReportService reportService; - - @Autowired - MemberRepository memberRepository; - - @Autowired - PostRepository postRepository; - - @Autowired - ReportRepository reportRepository; - - @Autowired - CommentRepository commentRepository; - - @Autowired - PostService postService; - - @Autowired - PostCommentService postCommentService; - - @Nested - @DisplayName("게시글 신고기능은") - class ReportPost { - - @Test - @DisplayName("정상적으로 동작한다.") - void reportPost() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - postRepository.save(post); - - ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); - - // when, then - assertDoesNotThrow(() -> reportService.report(reporter, request)); - } - - @Test - @DisplayName("없는 투표글을 신고하는 경우 예외가 발생한다.") - void reportNonExistPostThrowsException() { - // given - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - ReportRequest request = new ReportRequest(ReportType.POST, -1L, "불건전한 게시글"); - - // when, then - assertThatThrownBy(() -> reportService.report(writer, request)) - .isInstanceOf(NotFoundException.class) - .hasMessage("해당 게시글이 존재하지 않습니다."); - } - - @Test - @DisplayName("자신의 투표글을 신고하는 경우 예외가 발생한다.") - void reportOwnPostThrowsException() { - // given - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - postRepository.save(post); - - ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); - - // when, then - assertThatThrownBy(() -> reportService.report(writer, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("자신의 게시글은 신고할 수 없습니다."); - } - - @Test - @DisplayName("블라인드 처리된 투표글을 신고하는 경우 예외가 발생한다.") - void reportHiddenPost() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - post.blind(); - - postRepository.save(post); - - ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); - - // when, then - - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("이미 블라인드 처리된 글입니다."); - } - - @Test - @DisplayName("하나의 회원이 투표글을 중복하여 신고하면 예외를 던진다.") - void reportDuplicated() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - postRepository.save(post); - - ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); - - // when - reportService.report(reporter, request); - - // then - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("하나의 글에 대해서 중복하여 신고할 수 없습니다."); - } - - @Test - @DisplayName("투표글 신고가 5회가 되면 블라인드 처리가 된다.") - void reportAndBlind() { - // given - Member reporter1 = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member reporter2 = memberRepository.save(MemberFixtures.FEMALE_30.get()); - Member reporter3 = memberRepository.save(MemberFixtures.FEMALE_40.get()); - Member reporter4 = memberRepository.save(MemberFixtures.FEMALE_50.get()); - Member reporter5 = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - postRepository.save(post); - - ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); - - // when - reportService.report(reporter1, request); - reportService.report(reporter2, request); - reportService.report(reporter3, request); - reportService.report(reporter4, request); - reportService.report(reporter5, request); - - // then - final List responses = postService.getPostsGuest( - 0, - PostClosingType.ALL, - PostSortType.HOT, - null - ); - - assertAll( - () -> assertThat(post.isHidden()).isTrue(), - () -> assertThat(responses).isEmpty() - ); - } - - } - - @Nested - @DisplayName("댓글 신고기능은") - class ReportComment { - - @Test - @DisplayName("정상적으로 동작한다.") - void reportComment() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Comment comment = Comment.builder() - .post(post) - .member(writer) - .content("으어어어어") - .build(); - - postRepository.save(post); - commentRepository.save(comment); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 게시글"); - - // when, then - assertDoesNotThrow(() -> reportService.report(reporter, request)); - } - - @Test - @DisplayName("없는 댓글을 신고하는 경우 예외가 발생한다.") - void reportNonExistCommentThrowsException() { - // given - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, -1L, "불건전한 댓글"); - - // when, then - assertThatThrownBy(() -> reportService.report(writer, request)) - .isInstanceOf(NotFoundException.class) - .hasMessage("해당 댓글이 존재하지 않습니다."); - } - - @Test - @DisplayName("자신의 댓글을 신고하는 경우 예외가 발생한다.") - void reportOwnCommentThrowsException() { - // given - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Comment comment = Comment.builder() - .post(post) - .member(writer) - .content("으어어어어") - .build(); - - postRepository.save(post); - commentRepository.save(comment); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); - - // when, then - assertThatThrownBy(() -> reportService.report(writer, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("자신의 댓글은 신고할 수 없습니다."); - } - - @Test - @DisplayName("블라인드 처리된 댓글을 신고하는 경우 예외가 발생한다.") - void reportHiddenComment() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Comment comment = Comment.builder() - .post(post) - .member(writer) - .content("으어어어어") - .build(); - - postRepository.save(post); - commentRepository.save(comment); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); - - // when, then - comment.blind(); - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("이미 블라인드 처리된 댓글입니다."); - } - - @Test - @DisplayName("하나의 회원이 댓글을 중복하여 신고하면 예외를 던진다.") - void reportDuplicated() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Comment comment = Comment.builder() - .post(post) - .member(writer) - .content("으어어어어") - .build(); - - postRepository.save(post); - commentRepository.save(comment); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); - - // when - reportService.report(reporter, request); - - // then - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("하나의 댓글에 대해서 중복하여 신고할 수 없습니다."); - } - - @Test - @DisplayName("댓글 신고가 5회가 되면 블라인드 처리가 된다.") - void reportAndBlind() { - // given - Member reporter1 = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member reporter2 = memberRepository.save(MemberFixtures.FEMALE_30.get()); - Member reporter3 = memberRepository.save(MemberFixtures.FEMALE_40.get()); - Member reporter4 = memberRepository.save(MemberFixtures.FEMALE_50.get()); - Member reporter5 = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member writer = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - PostBody postBody = PostBody.builder() - .title("title") - .content("content") - .build(); - - Post post = Post.builder() - .writer(writer) - .postBody(postBody) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build(); - - Comment comment = Comment.builder() - .post(post) - .member(writer) - .content("으어어어어") - .build(); - - postRepository.save(post); - commentRepository.save(comment); - - ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); - - // when - reportService.report(reporter1, request); - reportService.report(reporter2, request); - reportService.report(reporter3, request); - reportService.report(reporter4, request); - reportService.report(reporter5, request); - - // then - assertAll( - () -> assertThat(comment.isHidden()).isTrue(), - () -> assertThat(postCommentService.getComments(post.getId())).isEmpty() - ); - } - - } - - @Nested - @DisplayName("닉네임 신고기능은") - class ReportNickname { - - @Test - @DisplayName("정상적으로 동작한다.") - void reportNickname() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); - Member reported = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); - - // when, then - assertDoesNotThrow(() -> reportService.report(reporter, request)); - } - - @Test - @DisplayName("자신의 닉네임을 신고하는 경우 예외가 발생한다.") - void reportOwnNicknameThrowsException() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_30.get()); - - ReportRequest request = new ReportRequest(ReportType.NICKNAME, reporter.getId(), "불건전한 닉네임"); - - // when, then - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("자신의 닉네임은 신고할 수 없습니다."); - } - - @Test - @DisplayName("하나의 회원이 다른 회원의 닉네임을 중복하여 신고하면 예외를 던진다.") - void reportDuplicated() { - // given - Member reporter = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member reported = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); - - // when - reportService.report(reporter, request); - - // then - assertThatThrownBy(() -> reportService.report(reporter, request)) - .isInstanceOf(BadRequestException.class) - .hasMessage("하나의 닉네임에 대해서 중복하여 신고할 수 없습니다."); - } - - @Test - @DisplayName("닉네임 신고가 3회가 되면 닉네임이 자동변경처리가 된다.") - void reportAndBlind() { - // given - Member reporter1 = memberRepository.save(MemberFixtures.FEMALE_20.get()); - Member reporter2 = memberRepository.save(MemberFixtures.FEMALE_30.get()); - Member reporter3 = memberRepository.save(MemberFixtures.FEMALE_40.get()); - Member reported = memberRepository.save(MemberFixtures.FEMALE_10.get()); - - ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); - - // when - reportService.report(reporter1, request); - reportService.report(reporter2, request); - reportService.report(reporter3, request); - - // then - assertThat(reported.getNickname()).contains("Pause1"); - } - } - -} diff --git a/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportCommentStrategyTest.java b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportCommentStrategyTest.java new file mode 100644 index 000000000..3067999d5 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportCommentStrategyTest.java @@ -0,0 +1,132 @@ +package com.votogether.domain.report.service.strategy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.service.PostCommentService; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.test.ServiceTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("댓글 신고기능은") +class ReportCommentStrategyTest extends ServiceTest { + + @Autowired + ReportCommentStrategy reportCommentStrategy; + + @Autowired + PostCommentService postCommentService; + + @Test + @DisplayName("정상적으로 동작한다.") + void reportComment() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 게시글"); + + // when, then + assertDoesNotThrow(() -> reportCommentStrategy.report(reporter, request)); + } + + @Test + @DisplayName("없는 댓글을 신고하는 경우 예외가 발생한다.") + void reportNonExistCommentThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, -1L, "불건전한 댓글"); + + // when, then + assertThatThrownBy(() -> reportCommentStrategy.report(writer, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("댓글이 존재하지 않습니다."); + } + + @Test + @DisplayName("자신의 댓글을 신고하는 경우 예외가 발생한다.") + void reportOwnCommentThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).writer(writer).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when, then + assertThatThrownBy(() -> reportCommentStrategy.report(writer, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("본인 댓글은 신고할 수 없습니다."); + } + + @Test + @DisplayName("블라인드 처리된 댓글을 신고하는 경우 예외가 발생한다.") + void reportHiddenComment() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when, then + comment.blind(); + assertThatThrownBy(() -> reportCommentStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 댓글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 댓글을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when + reportCommentStrategy.report(reporter, request); + + // then + assertThatThrownBy(() -> reportCommentStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 댓글에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("댓글 신고가 5회가 되면 블라인드 처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberTestPersister.builder().save(); + Member reporter2 = memberTestPersister.builder().save(); + Member reporter3 = memberTestPersister.builder().save(); + Member reporter4 = memberTestPersister.builder().save(); + Member reporter5 = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + Comment comment = commentTestPersister.builder().post(post).save(); + ReportRequest request = new ReportRequest(ReportType.COMMENT, comment.getId(), "불건전한 댓글"); + + // when + reportCommentStrategy.report(reporter1, request); + reportCommentStrategy.report(reporter2, request); + reportCommentStrategy.report(reporter3, request); + reportCommentStrategy.report(reporter4, request); + reportCommentStrategy.report(reporter5, request); + + // then + assertAll( + () -> assertThat(comment.isHidden()).isTrue(), + () -> assertThat(postCommentService.getComments(post.getId())).isEmpty() + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategyTest.java b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategyTest.java new file mode 100644 index 000000000..9b873f3ed --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportNicknameStrategyTest.java @@ -0,0 +1,101 @@ +package com.votogether.domain.report.service.strategy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.member.repository.MemberRepository; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.domain.report.repository.ReportRepository; +import com.votogether.global.exception.BadRequestException; +import com.votogether.test.annotation.ServiceTest; +import com.votogether.test.fixtures.MemberFixtures; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@ServiceTest +@DisplayName("닉네임 신고기능은") +class ReportNicknameStrategyTest { + + @Autowired + ReportNicknameStrategy reportNicknameStrategy; + + @Autowired + ReportRepository reportRepository; + + @Autowired + MemberRepository memberRepository; + + @Test + @DisplayName("정상적으로 동작한다.") + void reportNickname() { + // given + Member reporter = memberRepository.save(MemberFixtures.FEMALE_60.get()); + Member reported = memberRepository.save(MemberFixtures.FEMALE_30.get()); + + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when, then + assertDoesNotThrow(() -> reportNicknameStrategy.report(reporter, request)); + } + + @Test + @DisplayName("자신의 닉네임을 신고하는 경우 예외가 발생한다.") + void reportOwnNicknameThrowsException() { + // given + Member reporter = memberRepository.save(MemberFixtures.FEMALE_30.get()); + + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reporter.getId(), "불건전한 닉네임"); + + // when, then + assertThatThrownBy(() -> reportNicknameStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("자신의 닉네임은 신고할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 다른 회원의 닉네임을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberRepository.save(MemberFixtures.FEMALE_20.get()); + Member reported = memberRepository.save(MemberFixtures.FEMALE_10.get()); + + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when + reportNicknameStrategy.report(reporter, request); + + // then + assertThatThrownBy(() -> reportNicknameStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 닉네임에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("닉네임 신고가 3회가 되면 닉네임이 자동변경처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberRepository.save(MemberFixtures.FEMALE_20.get()); + Member reporter2 = memberRepository.save(MemberFixtures.FEMALE_30.get()); + Member reporter3 = memberRepository.save(MemberFixtures.FEMALE_40.get()); + Member reported = memberRepository.save(MemberFixtures.FEMALE_10.get()); + + ReportRequest request = new ReportRequest(ReportType.NICKNAME, reported.getId(), "불건전한 닉네임"); + + // when + reportNicknameStrategy.report(reporter1, request); + reportNicknameStrategy.report(reporter2, request); + reportNicknameStrategy.report(reporter3, request); + + // then + assertAll( + () -> assertThat(reported.getNickname()).contains("Pause1"), + () -> assertThat(reportRepository.findAll()).isEmpty() + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportPostStrategyTest.java b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportPostStrategyTest.java new file mode 100644 index 000000000..9b37c8bc1 --- /dev/null +++ b/backend/src/test/java/com/votogether/domain/report/service/strategy/ReportPostStrategyTest.java @@ -0,0 +1,136 @@ +package com.votogether.domain.report.service.strategy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.dto.response.post.PostResponse; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.vo.PostClosingType; +import com.votogether.domain.post.entity.vo.PostSortType; +import com.votogether.domain.post.service.PostGuestService; +import com.votogether.domain.report.dto.request.ReportRequest; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.global.exception.BadRequestException; +import com.votogether.global.exception.NotFoundException; +import com.votogether.test.ServiceTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("게시글 신고 기능은") +class ReportPostStrategyTest extends ServiceTest { + + @Autowired + ReportPostStrategy reportPostStrategy; + + @Autowired + PostGuestService postGuestService; + + @Test + @DisplayName("정상적으로 동작한다.") + void reportPost() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertDoesNotThrow(() -> reportPostStrategy.report(reporter, request)); + } + + @Test + @DisplayName("없는 투표글을 신고하는 경우 예외가 발생한다.") + void reportNonExistPostThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, -1L, "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportPostStrategy.report(writer, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("게시글이 존재하지 않습니다."); + } + + @Test + @DisplayName("자신의 투표글을 신고하는 경우 예외가 발생한다.") + void reportOwnPostThrowsException() { + // given + Member writer = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().writer(writer).save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportPostStrategy.report(writer, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("본인 게시글은 신고할 수 없습니다."); + } + + @Test + @DisplayName("블라인드 처리된 투표글을 신고하는 경우 예외가 발생한다.") + void reportHiddenPost() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + post.blind(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when, then + assertThatThrownBy(() -> reportPostStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("신고에 의해 숨겨진 게시글은 접근할 수 없습니다."); + } + + @Test + @DisplayName("하나의 회원이 투표글을 중복하여 신고하면 예외를 던진다.") + void reportDuplicated() { + // given + Member reporter = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when + reportPostStrategy.report(reporter, request); + + // then + assertThatThrownBy(() -> reportPostStrategy.report(reporter, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("하나의 글에 대해서 중복하여 신고할 수 없습니다."); + } + + @Test + @DisplayName("투표글 신고가 5회가 되면 블라인드 처리가 된다.") + void reportAndBlind() { + // given + Member reporter1 = memberTestPersister.builder().save(); + Member reporter2 = memberTestPersister.builder().save(); + Member reporter3 = memberTestPersister.builder().save(); + Member reporter4 = memberTestPersister.builder().save(); + Member reporter5 = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + ReportRequest request = new ReportRequest(ReportType.POST, post.getId(), "불건전한 게시글"); + + // when + reportPostStrategy.report(reporter1, request); + reportPostStrategy.report(reporter2, request); + reportPostStrategy.report(reporter3, request); + reportPostStrategy.report(reporter4, request); + reportPostStrategy.report(reporter5, request); + + // then + final List responses = postGuestService.getPosts( + 0, + PostClosingType.ALL, + PostSortType.HOT, + null + ); + assertAll( + () -> assertThat(post.isHidden()).isTrue(), + () -> assertThat(responses).isEmpty() + ); + } + +} diff --git a/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java b/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java index 476e83f5c..9cc23b96b 100644 --- a/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java +++ b/backend/src/test/java/com/votogether/domain/vote/repository/VoteRepositoryTest.java @@ -5,294 +5,283 @@ import com.votogether.domain.member.entity.Member; import com.votogether.domain.member.entity.vo.Gender; -import com.votogether.domain.member.repository.MemberRepository; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.repository.PostOptionRepository; -import com.votogether.domain.post.repository.PostRepository; import com.votogether.domain.vote.entity.Vote; -import com.votogether.domain.vote.repository.dto.VoteStatus; -import com.votogether.test.annotation.RepositoryTest; -import com.votogether.test.fixtures.MemberFixtures; -import com.votogether.test.persister.MemberTestPersister; -import com.votogether.test.persister.VoteTestPersister; -import java.time.LocalDateTime; +import com.votogether.domain.vote.repository.dto.VoteCountByAgeGroupAndGenderDto; +import com.votogether.domain.vote.repository.dto.VoteCountByAgeGroupAndGenderInterface; +import com.votogether.test.RepositoryTest; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -@RepositoryTest -class VoteRepositoryTest { - - @Autowired - MemberRepository memberRepository; - - @Autowired - PostRepository postRepository; +class VoteRepositoryTest extends RepositoryTest { @Autowired VoteRepository voteRepository; - @Autowired - PostOptionRepository postOptionRepository; - - @Autowired - VoteTestPersister voteTestPersister; - - @Autowired - MemberTestPersister memberTestPersister; - @Test - @DisplayName("투표를 저장한다.") - void save() { + @DisplayName("연령대, 성별로 그릅화하여 게시글 총 투표 수를 집계한다.") + void findPostVoteCountByAgeGroupAndGender() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(1) - .content("치킨") - .build() - ); - - Vote vote = Vote.builder() - .postOption(postOption) - .member(member) - .build(); + Member memberA = memberTestPersister.builder().birthYear(2014).gender(Gender.MALE).save(); // 9세 남성 + Member memberB = memberTestPersister.builder().birthYear(2013).gender(Gender.FEMALE).save(); // 10세 여성 + Member memberC = memberTestPersister.builder().birthYear(1990).gender(Gender.FEMALE).save(); // 33세 여성 + Member memberD = memberTestPersister.builder().birthYear(1986).gender(Gender.MALE).save(); // 37세 남성 + Member memberE = memberTestPersister.builder().birthYear(1963).gender(Gender.MALE).save(); // 60세 남성 + Member memberF = memberTestPersister.builder().birthYear(1951).gender(Gender.FEMALE).save(); // 72세 여성 + Member memberG = memberTestPersister.builder().birthYear(1936).gender(Gender.FEMALE).save(); // 87세 여성 + Member memberH = memberTestPersister.builder().birthYear(1924).gender(Gender.FEMALE).save(); // 99세 여성 + Post post = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(post).sequence(2).save(); + voteTestPersister.builder().postOption(postOptionA).member(memberA).save(); + voteTestPersister.builder().postOption(postOptionA).member(memberB).save(); + voteTestPersister.builder().postOption(postOptionA).member(memberC).save(); + voteTestPersister.builder().postOption(postOptionA).member(memberD).save(); + voteTestPersister.builder().postOption(postOptionB).member(memberE).save(); + voteTestPersister.builder().postOption(postOptionB).member(memberF).save(); + voteTestPersister.builder().postOption(postOptionB).member(memberG).save(); + voteTestPersister.builder().postOption(postOptionB).member(memberH).save(); // when - voteRepository.save(vote); + List voteCounts = + voteRepository.findPostVoteCountByAgeGroupAndGender(post.getId()); // then - assertThat(vote.getId()).isNotNull(); + List result = voteCounts.stream() + .map(VoteCountByAgeGroupAndGenderDto::from) + .toList(); + List expected = List.of( + new VoteCountByAgeGroupAndGenderDto(0, Gender.MALE, 1), + new VoteCountByAgeGroupAndGenderDto(1, Gender.FEMALE, 1), + new VoteCountByAgeGroupAndGenderDto(3, Gender.MALE, 1), + new VoteCountByAgeGroupAndGenderDto(3, Gender.FEMALE, 1), + new VoteCountByAgeGroupAndGenderDto(6, Gender.MALE, 1), + new VoteCountByAgeGroupAndGenderDto(6, Gender.FEMALE, 3) + ); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); } @Test - @DisplayName("멤버와 투표선택지를 통해 투표를 찾는다.") - void findByMemberAndPostOption() { + @DisplayName("연령대, 성별로 그릅화하여 게시글 옵션 총 투표 수를 집계한다.") + void findPostOptionVoteCountByAgeGroupAndGender() { // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(1) - .content("치킨") - .build() - ); - - Vote vote = voteRepository.save(Vote.builder() - .postOption(postOption) - .member(member) - .build()); + Member memberA = memberTestPersister.builder().birthYear(2014).gender(Gender.MALE).save(); // 9세 남성 + Member memberB = memberTestPersister.builder().birthYear(2013).gender(Gender.FEMALE).save(); // 10세 여성 + Member memberC = memberTestPersister.builder().birthYear(1990).gender(Gender.FEMALE).save(); // 33세 여성 + Member memberD = memberTestPersister.builder().birthYear(1986).gender(Gender.MALE).save(); // 37세 남성 + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + voteTestPersister.builder().postOption(postOption).member(memberA).save(); + voteTestPersister.builder().postOption(postOption).member(memberB).save(); + voteTestPersister.builder().postOption(postOption).member(memberC).save(); + voteTestPersister.builder().postOption(postOption).member(memberD).save(); // when - Vote findVote = voteRepository.findByMemberAndPostOption(member, postOption).get(); + List voteCounts = + voteRepository.findPostOptionVoteCountByAgeGroupAndGender(postOption.getId()); // then - assertThat(findVote).isSameAs(vote); + List result = voteCounts.stream() + .map(VoteCountByAgeGroupAndGenderDto::from) + .toList(); + List expected = List.of( + new VoteCountByAgeGroupAndGenderDto(0, Gender.MALE, 1), + new VoteCountByAgeGroupAndGenderDto(1, Gender.FEMALE, 1), + new VoteCountByAgeGroupAndGenderDto(3, Gender.MALE, 1), + new VoteCountByAgeGroupAndGenderDto(3, Gender.FEMALE, 1) + ); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); } - @Test - @DisplayName("멤버와 여러 투표선택지를 통해 투표를 찾는다.") - void findByMemberAndPostOptionIn() { - // given - Member member = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post postA = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOptionA = postOptionRepository.save( - PostOption.builder() - .post(postA) - .sequence(1) - .content("치킨") - .build() - ); - Post postB = postRepository.save( - Post.builder() - .writer(member) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOptionB = postOptionRepository.save( - PostOption.builder() - .post(postB) - .sequence(1) - .content("치킨") - .build() - ); + @Nested + @DisplayName("게시글의 회원 투표 조회") + class FindVoteByPostAndMember { - Vote voteA = Vote.builder() - .postOption(postOptionA) - .member(member) - .build(); - Vote voteB = Vote.builder() - .postOption(postOptionB) - .member(member) - .build(); - voteRepository.save(voteA); - voteRepository.save(voteB); + @Test + @DisplayName("투표가 존재하면 투표를 반환한다.") + void findVote() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + voteTestPersister.builder().postOption(postOption).member(member).save(); - // when - List votes = voteRepository.findAllByMemberAndPostOptionIn(member, List.of(postOptionA, postOptionB)); + // when + Optional result = voteRepository.findByMemberAndPostOptionPost(member, post); + + // then + assertThat(result).isPresent(); + } + + @Test + @DisplayName("투표가 존재하지 않으면 빈 값을 반환한다.") + void findEmpty() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + + // when + Optional result = voteRepository.findByMemberAndPostOptionPost(member, post); + + // then + assertThat(result).isNotPresent(); + } - // then - assertThat(votes).hasSize(2); } - @Test - @DisplayName("게시글의 연령대와 성별로 그룹화된 투표 통계를 조회한다.") - void findVoteCountByPostIdGroupByAgeRangeAndGender() { - // given - Member femaleEarly10 = memberRepository.save(MemberFixtures.FEMALE_10.get()); - Member male10 = memberRepository.save(MemberFixtures.MALE_10.get()); - Member male60 = memberRepository.save(MemberFixtures.MALE_60.get()); - Member female70 = memberRepository.save(MemberFixtures.FEMALE_70.get()); - Member female80 = memberRepository.save(MemberFixtures.FEMALE_80.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOptionA = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(1) - .content("치킨") - .build() - ); - PostOption postOptionB = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(2) - .content("피자") - .build() - ); + @Nested + @DisplayName("게시글 옵션의 회원 투표 조회") + class FindVoteByPostOptionAndMember { + + @Test + @DisplayName("투표가 존재하면 투표를 반환한다.") + void findVote() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + voteTestPersister.builder().postOption(postOption).member(member).save(); + + // when + Optional result = voteRepository.findByMemberAndPostOption(member, postOption); + + // then + assertThat(result).isPresent(); + } + + @Test + @DisplayName("투표가 존재하지 않으면 빈 값을 반환한다.") + void findEmpty() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + PostOption postOption = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + + // when + Optional result = voteRepository.findByMemberAndPostOption(member, postOption); + + // then + assertThat(result).isNotPresent(); + } - voteRepository.save(Vote.builder().member(femaleEarly10).postOption(postOptionA).build()); - voteRepository.save(Vote.builder().member(male10).postOption(postOptionB).build()); - voteRepository.save(Vote.builder().member(male60).postOption(postOptionA).build()); - voteRepository.save(Vote.builder().member(female70).postOption(postOptionB).build()); - voteRepository.save(Vote.builder().member(female80).postOption(postOptionA).build()); + } - // when - List result = voteRepository.findVoteCountByPostIdGroupByAgeRangeAndGender(post.getId()); + @Nested + @DisplayName("게시글 옵션 목록의 회원 투표 조회") + class FindVoteByPostOptionsAndMember { + + @Test + @DisplayName("투표가 존재하면 투표를 반환한다.") + void findVote() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(post).sequence(2).save(); + Vote vote = voteTestPersister.builder().postOption(postOptionB).member(member).save(); + + // when + List result = + voteRepository.findAllByMemberAndPostOptionIn(member, List.of(postOptionA, postOptionB)); + + // then + assertThat(result).containsExactly(vote); + } + + @Test + @DisplayName("투표가 존재하지 않으면 빈 값을 반환한다.") + void findEmpty() { + // given + Member member = memberTestPersister.builder().save(); + Post post = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(post).sequence(2).save(); + + // when + List result = + voteRepository.findAllByMemberAndPostOptionIn(member, List.of(postOptionA, postOptionB)); + + // then + assertThat(result).isEmpty(); + } + + } + + @Nested + @DisplayName("회원의 모든 투표 조회") + class FindVotesByMember { + + @Test + @DisplayName("투표가 존재하면 모든 투표를 조회한다.") + void findVotes() { + // given + Member member = memberTestPersister.builder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().sequence(1).save(); + Vote voteA = voteTestPersister.builder().postOption(postOptionA).member(member).save(); + Vote voteB = voteTestPersister.builder().postOption(postOptionB).member(member).save(); + + // when + List result = voteRepository.findAllByMember(member); + + // then + assertThat(result).containsExactly(voteA, voteB); + } + + @Test + @DisplayName("투표가 존재하지 않으면 빈 값을 반환한다.") + void findEmpty() { + // given + Member member = memberTestPersister.builder().save(); + + // when + List result = voteRepository.findAllByMember(member); + + // then + assertThat(result).isEmpty(); + } - // then - assertThat(result).containsExactly( - new VoteStatus(2005, Gender.FEMALE, 1), - new VoteStatus(2005, Gender.MALE, 1), - new VoteStatus(1955, Gender.MALE, 1), - new VoteStatus(1945, Gender.FEMALE, 1), - new VoteStatus(1935, Gender.FEMALE, 1) - ); } @Test - @DisplayName("게시글 투표 옵션의 연령대와 성별로 그룹화된 투표 통계를 조회한다.") - void findVoteCountByPostOptionIdGroupByAgeRangeAndGender() { + @DisplayName("회원의 투표 수를 조회한다.") + void countVotesByMember() { // given - Member femaleEarly10 = memberRepository.save(MemberFixtures.FEMALE_10.get()); - Member male10 = memberRepository.save(MemberFixtures.MALE_10.get()); - Member male60 = memberRepository.save(MemberFixtures.MALE_60.get()); - Member female70 = memberRepository.save(MemberFixtures.FEMALE_70.get()); - Member female80 = memberRepository.save(MemberFixtures.FEMALE_80.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - - Post post = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(1) - .content("치킨") - .build() - ); - - voteRepository.save(Vote.builder().member(femaleEarly10).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(male10).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(male60).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(female70).postOption(postOption).build()); - voteRepository.save(Vote.builder().member(female80).postOption(postOption).build()); + Member member = memberTestPersister.builder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().sequence(1).save(); + voteTestPersister.builder().postOption(postOptionA).member(member).save(); + voteTestPersister.builder().postOption(postOptionB).member(member).save(); // when - List result = - voteRepository.findVoteCountByPostOptionIdGroupByAgeRangeAndGender(postOption.getId()); + int result = voteRepository.countByMember(member); // then - assertThat(result).containsExactly( - new VoteStatus(2005, Gender.FEMALE, 1), - new VoteStatus(2005, Gender.MALE, 1), - new VoteStatus(1955, Gender.MALE, 1), - new VoteStatus(1945, Gender.FEMALE, 1), - new VoteStatus(1935, Gender.FEMALE, 1) - ); + assertThat(result).isEqualTo(2); } @Test - @DisplayName("해당 회원이 투표한 개수를 반환한다.") - void countByMember() { + @DisplayName("게시글의 모든 투표를 삭제한다.") + void deleteAllWithPostIdInBatch() { // given - Member member = memberRepository.save(MemberFixtures.MALE_10.get()); - Member writer = memberRepository.save(MemberFixtures.MALE_20.get()); - Post post = postRepository.save( - Post.builder() - .writer(writer) - .postBody(PostBody.builder().title("title").content("content").build()) - .deadline(LocalDateTime.of(2100, 7, 12, 0, 0)) - .build() - ); - PostOption postOption = postOptionRepository.save( - PostOption.builder() - .post(post) - .sequence(1) - .content("치킨") - .build() - ); - Vote vote = Vote.builder() - .member(member) - .postOption(postOption) - .build(); - - voteRepository.save(vote); + Post post = postTestPersister.postBuilder().save(); + PostOption postOptionA = postTestPersister.postOptionBuilder().post(post).sequence(1).save(); + PostOption postOptionB = postTestPersister.postOptionBuilder().post(post).sequence(2).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + voteTestPersister.builder().postOption(postOptionA).save(); + voteTestPersister.builder().postOption(postOptionB).save(); + voteTestPersister.builder().postOption(postOptionB).save(); // when - int numberOfVote = voteRepository.countByMember(member); + voteRepository.deleteAllWithPostOptionIdsInBatch(List.of(postOptionA.getId(), postOptionB.getId())); // then - assertThat(numberOfVote).isEqualTo(1); + assertThat(voteRepository.findAll()).isEmpty(); } @Test diff --git a/backend/src/test/java/com/votogether/infra/image/ImageNameTest.java b/backend/src/test/java/com/votogether/infra/image/ImageNameTest.java new file mode 100644 index 000000000..bb3a69b55 --- /dev/null +++ b/backend/src/test/java/com/votogether/infra/image/ImageNameTest.java @@ -0,0 +1,68 @@ +package com.votogether.infra.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.votogether.global.exception.ImageException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +class ImageNameTest { + + @Nested + @DisplayName("이미지명 생성") + class ImageNameCreate { + + @Test + @DisplayName("파일 이름이 정상적이면 이미지명을 생성한다.") + void success() { + // given + String fileName = "image.png"; + + // when + String result = ImageName.from(fileName); + + // then + assertThat(result).contains(".png"); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("파일 이름이 존재하지 않거나 비어있으면 예외를 던진다.") + void emptyFileName(String fileName) { + // given, when, then + assertThatThrownBy(() -> ImageName.from(fileName)) + .isInstanceOf(ImageException.class) + .hasMessage("원본 이미지명이 존재하지 않습니다."); + } + + @Test + @DisplayName("확장자가 존재하지 않으면 예외를 던진다") + void emptyExtension() { + // given + String fileName = "image"; + + // when, then + assertThatThrownBy(() -> ImageName.from(fileName)) + .isInstanceOf(ImageException.class) + .hasMessage("이미지 확장자가 존재하지 않습니다."); + } + + @Test + @DisplayName("확장자가 이미지 확장자가 아니라면 예외를 던진다.") + void invalidExtension() { + // given + String fileName = "image.txt"; + + // when, then + assertThatThrownBy(() -> ImageName.from(fileName)) + .isInstanceOf(ImageException.class) + .hasMessage("이미지 파일만 업로드할 수 있습니다."); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/infra/image/LocalUploaderTest.java b/backend/src/test/java/com/votogether/infra/image/LocalUploaderTest.java new file mode 100644 index 000000000..8e521f488 --- /dev/null +++ b/backend/src/test/java/com/votogether/infra/image/LocalUploaderTest.java @@ -0,0 +1,155 @@ +package com.votogether.infra.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.votogether.global.exception.ImageException; +import com.votogether.test.ServiceTest; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +class LocalUploaderTest extends ServiceTest { + + @Autowired + LocalUploader localUploader; + + @AfterEach + void tearDown( + @Value("${image.upload_directory}") String uploadDirectory + ) { + File folder = new File(uploadDirectory); + if (folder.exists()) { + deleteFileRecursive(folder); + } + } + + private static void deleteFileRecursive(File file) { + if (file.isDirectory()) { + File[] files = file.listFiles(); + if (files == null) { + return; + } + + for (File child : files) { + deleteFileRecursive(child); + } + } + file.delete(); + } + + @Nested + @DisplayName("이미지 업로드") + class ImageUpload { + + @Test + @DisplayName("정상적인 요청이라면 이미지를 업로드한다.") + void success() { + // given + MultipartFile multipartFile = mockingMultipartFile(); + + // when + String imagePath = localUploader.upload(multipartFile); + + // then + File file = new File(imagePath); + assertThat(file).exists(); + } + + @Test + @DisplayName("이미지 파일이 존재하지 않으면 null 이미지 경로를 반환한다.") + void nullImage() { + // given + MultipartFile multipartFile = null; + + // when + String imagePath = localUploader.upload(multipartFile); + + // then + assertThat(imagePath).isNull(); + } + + @Test + @DisplayName("이미지 파일이 비어있으면 null 이미지 경로를 반환한다.") + void emptyImage() { + // given + MultipartFile multipartFile = new MockMultipartFile( + "images", + "votogether.png", + MediaType.IMAGE_JPEG_VALUE, + "".getBytes() + ); + + // when + String imagePath = localUploader.upload(multipartFile); + + // then + assertThat(imagePath).isNull(); + } + + @Test + @DisplayName("이미지 파일이 아니라면 예외를 던진다.") + void invalidImage() { + // given + MultipartFile multipartFile = new MockMultipartFile( + "images", + "votogether.txt", + MediaType.TEXT_PLAIN_VALUE, + "hello".getBytes() + ); + + // when, then + assertThatThrownBy(() -> localUploader.upload(multipartFile)) + .isInstanceOf(ImageException.class) + .hasMessage("이미지 파일만 업로드할 수 있습니다."); + } + + } + + @Test + @DisplayName("이미지를 삭제한다.") + void deleteImage() { + // given + MultipartFile multipartFile = mockingMultipartFile(); + String imagePath = localUploader.upload(multipartFile); + + // when + localUploader.delete(imagePath); + + // then + File file = new File(imagePath); + assertThat(file).doesNotExist(); + } + + private MultipartFile mockingMultipartFile() { + return new MockMultipartFile( + "images", + "votogether.png", + MediaType.IMAGE_JPEG_VALUE, + generateMockImage() + ); + } + + private byte[] generateMockImage() { + BufferedImage image = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + throw new ImageException(ImageExceptionType.IMAGE_TRANSFER); + } + } + +} diff --git a/backend/src/test/java/com/votogether/test/ControllerTest.java b/backend/src/test/java/com/votogether/test/ControllerTest.java new file mode 100644 index 000000000..a5e9d3508 --- /dev/null +++ b/backend/src/test/java/com/votogether/test/ControllerTest.java @@ -0,0 +1,30 @@ +package com.votogether.test; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import com.votogether.domain.member.service.MemberService; +import com.votogether.global.jwt.TokenPayload; +import com.votogether.global.jwt.TokenProcessor; +import com.votogether.test.fixtures.MemberFixtures; +import org.springframework.boot.test.mock.mockito.MockBean; + +public class ControllerTest { + + protected static final String BEARER_TOKEN = "Bearer token"; + + @MockBean + TokenProcessor tokenProcessor; + + @MockBean + MemberService memberService; + + protected void mockingAuthArgumentResolver() throws Exception { + TokenPayload tokenPayload = new TokenPayload(1L, 1L, 1L); + given(tokenProcessor.resolveToken(anyString())).willReturn("token"); + given(tokenProcessor.parseToken(anyString())).willReturn(tokenPayload); + given(memberService.findById(anyLong())).willReturn(MemberFixtures.MALE_20.get()); + } + +} diff --git a/backend/src/test/java/com/votogether/test/RepositoryTest.java b/backend/src/test/java/com/votogether/test/RepositoryTest.java new file mode 100644 index 000000000..6bdc1a9dd --- /dev/null +++ b/backend/src/test/java/com/votogether/test/RepositoryTest.java @@ -0,0 +1,42 @@ +package com.votogether.test; + +import com.votogether.global.config.JpaConfig; +import com.votogether.global.config.QuerydslConfig; +import com.votogether.test.persister.CategoryTestPersister; +import com.votogether.test.persister.CommentTestPersister; +import com.votogether.test.persister.MemberTestPersister; +import com.votogether.test.persister.Persister; +import com.votogether.test.persister.PostTestPersister; +import com.votogether.test.persister.ReportTestPersister; +import com.votogether.test.persister.VoteTestPersister; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; + +@Import({JpaConfig.class, QuerydslConfig.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DataJpaTest(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Persister.class)) +public class RepositoryTest { + + @Autowired + protected MemberTestPersister memberTestPersister; + + @Autowired + protected CategoryTestPersister categoryTestPersister; + + @Autowired + protected PostTestPersister postTestPersister; + + @Autowired + protected CommentTestPersister commentTestPersister; + + @Autowired + protected ReportTestPersister reportTestPersister; + + @Autowired + protected VoteTestPersister voteTestPersister; + +} diff --git a/backend/src/test/java/com/votogether/test/ServiceTest.java b/backend/src/test/java/com/votogether/test/ServiceTest.java new file mode 100644 index 000000000..64a3c3fd9 --- /dev/null +++ b/backend/src/test/java/com/votogether/test/ServiceTest.java @@ -0,0 +1,35 @@ +package com.votogether.test; + +import com.votogether.test.persister.CategoryTestPersister; +import com.votogether.test.persister.CommentTestPersister; +import com.votogether.test.persister.MemberTestPersister; +import com.votogether.test.persister.PostTestPersister; +import com.votogether.test.persister.ReportTestPersister; +import com.votogether.test.persister.VoteTestPersister; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +public class ServiceTest { + + @Autowired + protected MemberTestPersister memberTestPersister; + + @Autowired + protected CategoryTestPersister categoryTestPersister; + + @Autowired + protected PostTestPersister postTestPersister; + + @Autowired + protected CommentTestPersister commentTestPersister; + + @Autowired + protected ReportTestPersister reportTestPersister; + + @Autowired + protected VoteTestPersister voteTestPersister; + +} diff --git a/backend/src/test/java/com/votogether/test/persister/CategoryTestPersister.java b/backend/src/test/java/com/votogether/test/persister/CategoryTestPersister.java new file mode 100644 index 000000000..59c1a5150 --- /dev/null +++ b/backend/src/test/java/com/votogether/test/persister/CategoryTestPersister.java @@ -0,0 +1,36 @@ +package com.votogether.test.persister; + +import com.votogether.domain.category.entity.Category; +import com.votogether.domain.category.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; + +@RequiredArgsConstructor +@Persister +public class CategoryTestPersister { + + private final CategoryRepository categoryRepository; + + public CategoryBuilder builder() { + return new CategoryBuilder(); + } + + public final class CategoryBuilder { + + private String name; + + public CategoryBuilder name(String name) { + this.name = name; + return this; + } + + public Category save() { + Category category = Category.builder() + .name(name == null ? RandomStringUtils.random(5, true, false) : name) + .build(); + return categoryRepository.save(category); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/test/persister/CommentTestPersister.java b/backend/src/test/java/com/votogether/test/persister/CommentTestPersister.java new file mode 100644 index 000000000..7a2961a22 --- /dev/null +++ b/backend/src/test/java/com/votogether/test/persister/CommentTestPersister.java @@ -0,0 +1,53 @@ +package com.votogether.test.persister; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.post.entity.Post; +import com.votogether.domain.post.entity.comment.Comment; +import com.votogether.domain.post.repository.CommentRepository; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Persister +public class CommentTestPersister { + + private final CommentRepository commentRepository; + private final MemberTestPersister memberTestPersister; + private final PostTestPersister postTestPersister; + + public CommentBuilder builder() { + return new CommentBuilder(); + } + + public final class CommentBuilder { + + private Post post; + private Member writer; + private String content; + + public CommentBuilder post(Post post) { + this.post = post; + return this; + } + + public CommentBuilder writer(Member writer) { + this.writer = writer; + return this; + } + + public CommentBuilder content(String content) { + this.content = content; + return this; + } + + public Comment save() { + Comment comment = Comment.builder() + .post(post == null ? postTestPersister.postBuilder().save() : post) + .writer(writer == null ? memberTestPersister.builder().save() : writer) + .content(content == null ? "hello" : content) + .build(); + return commentRepository.save(comment); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/test/persister/MemberTestPersister.java b/backend/src/test/java/com/votogether/test/persister/MemberTestPersister.java index 4e111c4bc..d446cc16e 100644 --- a/backend/src/test/java/com/votogether/test/persister/MemberTestPersister.java +++ b/backend/src/test/java/com/votogether/test/persister/MemberTestPersister.java @@ -35,8 +35,8 @@ public MemberBuilder gender(Gender gender) { return this; } - public MemberBuilder birthday(Integer birthday) { - this.birthYear = birthday; + public MemberBuilder birthYear(Integer birthYear) { + this.birthYear = birthYear; return this; } @@ -56,7 +56,7 @@ public Member save() { .gender(gender == null ? Gender.MALE : gender) .birthYear(birthYear == null ? 1995 : birthYear) .socialType(socialType == null ? SocialType.KAKAO : socialType) - .socialId(socialId == null ? "id" : socialId) + .socialId(socialId == null ? RandomStringUtils.random(10, true, true) : socialId) .build(); return memberRepository.save(member); } diff --git a/backend/src/test/java/com/votogether/test/persister/PostOptionTestPersister.java b/backend/src/test/java/com/votogether/test/persister/PostOptionTestPersister.java deleted file mode 100644 index f2eaa8347..000000000 --- a/backend/src/test/java/com/votogether/test/persister/PostOptionTestPersister.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.votogether.test.persister; - -import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostOption; -import com.votogether.domain.post.repository.PostOptionRepository; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.RandomStringUtils; - -@RequiredArgsConstructor -@Persister -public class PostOptionTestPersister { - - private final PostTestPersister postTestPersister; - private final PostOptionRepository postOptionRepository; - - public PostOptionBuilder builder() { - return new PostOptionBuilder(); - } - - public final class PostOptionBuilder { - - private Post post; - private int sequence; - private String content; - private String imageUrl; - - public PostOptionBuilder post(Post post) { - this.post = post; - return this; - } - - public PostOptionBuilder sequence(int sequence) { - this.sequence = sequence; - return this; - } - - public PostOptionBuilder content(String content) { - this.content = content; - return this; - } - - public PostOptionBuilder imageUrl(String imageUrl) { - this.imageUrl = imageUrl; - return this; - } - - public PostOption save() { - PostOption postOption = PostOption.builder() - .post(post == null ? postTestPersister.builder().save() : post) - .sequence(sequence) - .content(content == null ? RandomStringUtils.random(10, true, true) : content) - .imageUrl(imageUrl) - .build(); - return postOptionRepository.save(postOption); - } - - } - -} diff --git a/backend/src/test/java/com/votogether/test/persister/PostTestPersister.java b/backend/src/test/java/com/votogether/test/persister/PostTestPersister.java index 7f2e3cfb0..92c67df63 100644 --- a/backend/src/test/java/com/votogether/test/persister/PostTestPersister.java +++ b/backend/src/test/java/com/votogether/test/persister/PostTestPersister.java @@ -1,8 +1,14 @@ package com.votogether.test.persister; +import com.votogether.domain.category.entity.Category; import com.votogether.domain.member.entity.Member; import com.votogether.domain.post.entity.Post; -import com.votogether.domain.post.entity.PostBody; +import com.votogether.domain.post.entity.PostCategory; +import com.votogether.domain.post.entity.PostContentImage; +import com.votogether.domain.post.entity.PostOption; +import com.votogether.domain.post.repository.PostCategoryRepository; +import com.votogether.domain.post.repository.PostContentImageRepository; +import com.votogether.domain.post.repository.PostOptionRepository; import com.votogether.domain.post.repository.PostRepository; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; @@ -11,26 +17,49 @@ @Persister public class PostTestPersister { - private final MemberTestPersister memberTestPersister; private final PostRepository postRepository; + private final PostCategoryRepository postCategoryRepository; + private final PostContentImageRepository postContentImageRepository; + private final PostOptionRepository postOptionRepository; + private final MemberTestPersister memberTestPersister; + private final CategoryTestPersister categoryTestPersister; - public PostBuilder builder() { + public PostBuilder postBuilder() { return new PostBuilder(); } + public PostCategoryBuilder postCategoryBuilder() { + return new PostCategoryBuilder(); + } + + public PostContentImageBuilder postContentImageBuilder() { + return new PostContentImageBuilder(); + } + + public PostOptionBuilder postOptionBuilder() { + return new PostOptionBuilder(); + } + public final class PostBuilder { private Member writer; - private PostBody postBody; + private String title; + private String content; private LocalDateTime deadline; + private boolean isHidden; public PostBuilder writer(Member writer) { this.writer = writer; return this; } - public PostBuilder postBody(PostBody postBody) { - this.postBody = postBody; + public PostBuilder title(String title) { + this.title = title; + return this; + } + + public PostBuilder content(String content) { + this.content = content; return this; } @@ -39,22 +68,113 @@ public PostBuilder deadline(LocalDateTime deadline) { return this; } + public PostBuilder blind() { + this.isHidden = true; + return this; + } + public Post save() { Post post = Post.builder() .writer(writer == null ? memberTestPersister.builder().save() : writer) - .postBody(postBody == null ? generatePostBody() : postBody) - .deadline(deadline == null ? LocalDateTime.of(2100, 12, 25, 0, 0) : deadline) + .title(title == null ? "title" : title) + .content(content == null ? "content" : content) + .deadline(deadline == null ? LocalDateTime.now().plusDays(14) : deadline) .build(); + if (isHidden) { + post.blind(); + } + return postRepository.save(post); } - private PostBody generatePostBody() { - return PostBody.builder() - .title("title") - .content("content") + } + + public final class PostCategoryBuilder { + + private Post post; + private Category category; + + public PostCategoryBuilder post(Post post) { + this.post = post; + return this; + } + + public PostCategoryBuilder category(Category category) { + this.category = category; + return this; + } + + public PostCategory save() { + PostCategory postCategory = PostCategory.builder() + .post(post == null ? postBuilder().save() : post) + .category(category == null ? categoryTestPersister.builder().save() : category) + .build(); + return postCategoryRepository.save(postCategory); + } + + } + + public final class PostContentImageBuilder { + + private Post post; + private String imageUrl; + + public PostContentImageBuilder post(Post post) { + this.post = post; + return this; + } + + public PostContentImageBuilder imageUrl(String imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + public PostContentImage save() { + PostContentImage postContentImage = PostContentImage.builder() + .post(post == null ? postBuilder().save() : post) + .imageUrl(imageUrl == null ? "image.png" : imageUrl) .build(); + return postContentImageRepository.save(postContentImage); + } + + } + + public final class PostOptionBuilder { + + private Post post; + private int sequence; + private String content; + private String imageUrl; + + public PostOptionBuilder post(Post post) { + this.post = post; + return this; + } + + public PostOptionBuilder sequence(int sequence) { + this.sequence = sequence; + return this; + } + + public PostOptionBuilder content(String content) { + this.content = content; + return this; } + public PostOptionBuilder imageUrl(String imageUrl) { + this.imageUrl = imageUrl; + return this; + } + + public PostOption save() { + PostOption postOption = PostOption.builder() + .post(post == null ? postBuilder().save() : post) + .sequence(sequence) + .content(content == null ? "content" : content) + .imageUrl(imageUrl) + .build(); + return postOptionRepository.save(postOption); + } } } diff --git a/backend/src/test/java/com/votogether/test/persister/ReportTestPersister.java b/backend/src/test/java/com/votogether/test/persister/ReportTestPersister.java new file mode 100644 index 000000000..30c057db3 --- /dev/null +++ b/backend/src/test/java/com/votogether/test/persister/ReportTestPersister.java @@ -0,0 +1,59 @@ +package com.votogether.test.persister; + +import com.votogether.domain.member.entity.Member; +import com.votogether.domain.report.entity.Report; +import com.votogether.domain.report.entity.vo.ReportType; +import com.votogether.domain.report.repository.ReportRepository; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Persister +public class ReportTestPersister { + + private final ReportRepository reportRepository; + private final MemberTestPersister memberTestPersister; + + public ReportBuilder builder() { + return new ReportBuilder(); + } + + public final class ReportBuilder { + + private Member member; + private ReportType reportType; + private Long targetId; + private String reason; + + public ReportBuilder member(Member member) { + this.member = member; + return this; + } + + public ReportBuilder reportType(ReportType reportType) { + this.reportType = reportType; + return this; + } + + public ReportBuilder targetId(Long targetId) { + this.targetId = targetId; + return this; + } + + public ReportBuilder reason(String reason) { + this.reason = reason; + return this; + } + + public Report save() { + Report report = Report.builder() + .member(member == null ? memberTestPersister.builder().save() : member) + .reportType(reportType == null ? ReportType.POST : reportType) + .targetId(targetId == null ? 1L : targetId) + .reason(reason == null ? "invalid" : reason) + .build(); + return reportRepository.save(report); + } + + } + +} diff --git a/backend/src/test/java/com/votogether/test/persister/VoteTestPersister.java b/backend/src/test/java/com/votogether/test/persister/VoteTestPersister.java index 19d1af3fe..8901024e1 100644 --- a/backend/src/test/java/com/votogether/test/persister/VoteTestPersister.java +++ b/backend/src/test/java/com/votogether/test/persister/VoteTestPersister.java @@ -10,9 +10,9 @@ @Persister public class VoteTestPersister { - private final MemberTestPersister memberTestPersister; - private final PostOptionTestPersister postOptionTestPersister; private final VoteRepository voteRepository; + private final MemberTestPersister memberTestPersister; + private final PostTestPersister postTestPersister; public VoteBuilder builder() { return new VoteBuilder(); @@ -36,7 +36,7 @@ public VoteBuilder postOption(PostOption postOption) { public Vote save() { Vote vote = Vote.builder() .member(member == null ? memberTestPersister.builder().save() : member) - .postOption(postOption == null ? postOptionTestPersister.builder().save() : postOption) + .postOption(postOption == null ? postTestPersister.postOptionBuilder().save() : postOption) .build(); return voteRepository.save(vote); } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index f0314148f..674d16ad2 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -19,8 +19,8 @@ spring: servlet: multipart: - max-file-size: 30MB - max-request-size: 30MB + max-file-size: 5MB + max-request-size: 35MB h2: console: @@ -38,6 +38,12 @@ logging: server: forward-headers-strategy: FRAMEWORK + tomcat: + max-http-form-post-size: 35MB + accept-count: 100 + max-connections: 8192 + threads: + max: 200 springdoc: swagger-ui: @@ -60,3 +66,7 @@ jwt: secret-key: abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabc access-expiration-time: 100000 refresh-expiration-time: 222222 + +image: + upload_url: ${user.dir} + upload_directory: static/images diff --git a/frontend/.gitignore b/frontend/.gitignore index 1a4f00cb9..2a5ab3165 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,5 @@ /node_modules .env /dist +localhost.pem +localhost-key.pem diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index ac95e4da7..06768b5b8 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -2,6 +2,15 @@ import path from 'path'; import type { StorybookConfig } from '@storybook/react-webpack5'; const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); +const webpack = require('webpack'); + +function disableChunkSplitting(config) { + config.optimization = { splitChunks: { chunks: 'async' } }; + config.output = { ...config.output, chunkFilename: '[chunkhash].chunk.js' }; + config.plugins.push(new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); + + return config; +} const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -31,7 +40,8 @@ const config: StorybookConfig = { configFile: path.resolve(__dirname, '../tsconfig.json'), }) ); - return config; + + return disableChunkSplitting(config); }, staticDirs: ['./public'], env: config => ({ diff --git a/frontend/__test__/convertTimeToWord.test.ts b/frontend/__test__/convertTimeToWord.test.ts new file mode 100644 index 000000000..76be9ad6e --- /dev/null +++ b/frontend/__test__/convertTimeToWord.test.ts @@ -0,0 +1,61 @@ +import { convertTimeToWord } from '@utils/time'; + +describe('게시글 작성시간을 숫자 문자열로 받아 현재 시간과 비교해 반올림한 차이를 한글로 반환하는 유틸함수를 테스트한다.', () => { + test('2023-01-01 12:00에 작성한 글은 2023-01-01 12:05을 기준으로 "5분"이 반환된다.', () => { + const nowDate = new Date('2023-01-01 12:05'); + const result = convertTimeToWord('2023-01-01 12:00', nowDate); + + expect(result).toBe('5분 전 작성'); + }); + + test('2023-01-01 12:00에 작성한 글은 2023-01-01 20:10을 기준으로 "8시간"이 반환된다.', () => { + const nowDate = new Date('2023-01-01 20:10'); + const result = convertTimeToWord('2023-01-01 12:00', nowDate); + + expect(result).toBe('8시간 전 작성'); + }); + + test('2023-01-01 12:00에 작성한 글은 2023-01-02 13:00을 기준으로 "1일"이 반환된다.', () => { + const nowDate = new Date('2023-01-02 13:00'); + const result = convertTimeToWord('2023-01-01 12:00', nowDate); + + expect(result).toBe('1일 전 작성'); + }); + + test('2023-01-01 12:00에 작성한 글은 2023-01-12 13:00을 기준으로 "11일"이 반환된다.', () => { + const nowDate = new Date('2023-01-12 13:00'); + const result = convertTimeToWord('2023-01-01 12:00', nowDate); + + expect(result).toBe('11일 전 작성'); + }); + + test('작성된지 30일 이상이라면 작성 날짜를 반환한다.', () => { + const nowDate = new Date('2023-02-01 13:00'); + const result = convertTimeToWord('2023-01-01 12:00', nowDate); + + expect(result).toBe('2023-01-01'); + }); +}); + +describe('게시글 마감시간을 숫자 문자열로 받아 현재 시간과 비교해 반올림한 차이를 한글로 반환하는 유틸함수를 테스트한다.', () => { + test('2023-01-01 12:00 기준으로 2023-01-01 12:10가 마감인 경우 "10분 후 마감"이 반환된다.', () => { + const nowDate = new Date('2023-01-01 12:00'); + const result = convertTimeToWord('2023-01-01 12:10', nowDate); + + expect(result).toBe('10분 후 마감'); + }); + + test('2023-01-01 12:00 기준으로 2023-01-01 18:00가 마감인 경우 "6시간 후 마감"이 반환된다.', () => { + const nowDate = new Date('2023-01-01 12:00'); + const result = convertTimeToWord('2023-01-01 18:00', nowDate); + + expect(result).toBe('6시간 후 마감'); + }); + + test('2023-01-01 12:00 기준으로 2023-01-03 12:00가 마감인 경우 "2일 후 마감"이 반환된다.', () => { + const nowDate = new Date('2023-01-01 12:00'); + const result = convertTimeToWord('2023-01-03 12:00', nowDate); + + expect(result).toBe('2일 후 마감'); + }); +}); diff --git a/frontend/__test__/deleteOverlappingNewLine.test.ts b/frontend/__test__/deleteOverlappingNewLine.test.ts new file mode 100644 index 000000000..b228dda1a --- /dev/null +++ b/frontend/__test__/deleteOverlappingNewLine.test.ts @@ -0,0 +1,59 @@ +import { deleteOverlappingNewLine } from '@utils/post/deleteOverlappingNewLine'; + +describe('연속된 개행은 하나의 개행으로 처리하는 유틸함수를 테스트한다.', () => { + test('개행이 없는 문자열은 인자와 동일한 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다. 동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(text); + }); + + test('연속된 개행이 없는 문자열은 인자와 동일한 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(text); + }); + + test('2회 연속된 개행이 있는 문자열은 인자와 동일한 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const expectText = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(expectText); + }); + + test('5회 연속된 개행이 있는 문자열은 인자와 연속된 개행이 5회 개행으로 바뀐 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const expectText = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(expectText); + }); + + test('7회 연속된 개행이 있는 문자열은 인자와 연속된 개행이 5회 개행으로 바뀐 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const expectText = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(expectText); + }); + + test('10회 연속된 개행이 있는 문자열은 인자와 연속된 개행이 5회 개행으로 바뀐 결과를 반환한다.', () => { + const text = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const expectText = + '안녕하세요. 이것은 유틸함수를 테스트하기 위한 문자열입니다.\n\n\n\n\n동일한 결과물이 나와야 옳은 작동을 하는 유틸함수 입니다.'; + const changedText = deleteOverlappingNewLine(text); + + expect(changedText).toBe(expectText); + }); +}); diff --git a/frontend/__test__/getSelectedTimeOption.test.ts b/frontend/__test__/getSelectedTimeOption.test.ts index 7324c2ebc..46422f454 100644 --- a/frontend/__test__/getSelectedTimeOption.test.ts +++ b/frontend/__test__/getSelectedTimeOption.test.ts @@ -1,64 +1,64 @@ import { getSelectedTimeOption } from '@utils/post/getSelectedTimeOption'; -describe('getSelectedTimeOption 함수에서 day, hour, minute 객체를 입력받아 "10분" | "30분" | "1시간" | "6시간" | "1일" | "사용자 지정" | null 을 반환한다.', () => { - test('10분 객체를 입력했을 때 10분을 반환한다.', () => { +describe('getSelectedTimeOption 함수에서 day, hour, minute 객체를 입력받아 "1일" | "3일" | "5일" | "7일" | "14일" | "사용자 지정" | null 을 반환한다.', () => { + test('1일 객체를 입력했을 때 1일을 반환한다.', () => { const time = { - day: 0, + day: 1, hour: 0, - minute: 10, + minute: 0, }; const result = getSelectedTimeOption(time); - expect(result).toBe('10분'); + expect(result).toBe('1일'); }); - test('30분 객체를 입력했을 때 30분을 반환한다.', () => { + test('3일 객체를 입력했을 때 3일을 반환한다.', () => { const time = { - day: 0, + day: 3, hour: 0, - minute: 30, + minute: 0, }; const result = getSelectedTimeOption(time); - expect(result).toBe('30분'); + expect(result).toBe('3일'); }); - test('1시간 객체를 입력했을 때 1시간을 반환한다.', () => { + test('5일 객체를 입력했을 때 5일을 반환한다.', () => { const time = { - day: 0, - hour: 1, + day: 5, + hour: 0, minute: 0, }; const result = getSelectedTimeOption(time); - expect(result).toBe('1시간'); + expect(result).toBe('5일'); }); - test('6시간 객체를 입력했을 때 6시간을 반환한다.', () => { + test('7일 객체를 입력했을 때 7일을 반환한다.', () => { const time = { - day: 0, - hour: 6, + day: 7, + hour: 0, minute: 0, }; const result = getSelectedTimeOption(time); - expect(result).toBe('6시간'); + expect(result).toBe('7일'); }); - test('1일 객체를 입력했을 때 1일을 반환한다.', () => { + test('14일 객체를 입력했을 때 14일을 반환한다.', () => { const time = { - day: 1, + day: 14, hour: 0, minute: 0, }; const result = getSelectedTimeOption(time); - expect(result).toBe('1일'); + expect(result).toBe('14일'); }); test('2일 객체를 입력했을 때 사용자지정을 반환한다.', () => { diff --git a/frontend/__test__/hooks/useWritingOption.test.tsx b/frontend/__test__/hooks/useWritingOption.test.tsx index 1741b6885..f0908cc12 100644 --- a/frontend/__test__/hooks/useWritingOption.test.tsx +++ b/frontend/__test__/hooks/useWritingOption.test.tsx @@ -44,7 +44,9 @@ describe('useWritingOption 훅을 테스트 한다.', () => { const { optionList } = result.current; - expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION); + expect(optionList).toEqual( + MOCK_MIN_VOTE_OPTION.map(option => ({ ...option, isServerId: true })) + ); }); test('투표 선택지를 추가할 수 있어야 한다. 생성된 선택지는 text와 imageUrl 값을 가지고 있다.', () => { @@ -76,7 +78,9 @@ describe('useWritingOption 훅을 테스트 한다.', () => { const { optionList } = result.current; - expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION); + expect(optionList).toEqual( + MOCK_MAX_VOTE_OPTION.map(option => ({ ...option, isServerId: true })) + ); }); test('투표 선택지가 3개 이상일때는 투표 선택지의 아이디를 이용하여 삭제할 수 있다.', () => { @@ -90,7 +94,9 @@ describe('useWritingOption 훅을 테스트 한다.', () => { const { optionList } = result.current; - expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION.slice(1, 5)); + expect(optionList).toEqual( + MOCK_MAX_VOTE_OPTION.slice(1, 5).map(option => ({ ...option, isServerId: true })) + ); }); test('투표 선택지가 2개일때는 삭제할 수 없다.', () => { @@ -104,7 +110,9 @@ describe('useWritingOption 훅을 테스트 한다.', () => { const { optionList } = result.current; - expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION); + expect(optionList).toEqual( + MOCK_MIN_VOTE_OPTION.map(option => ({ ...option, isServerId: true })) + ); }); test('선택한 이미지가 있을 때 취소할 수 있다.', () => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd8b4dfad..94a889696 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@tanstack/react-query": "^4.29.19", + "browser-image-compression": "^2.0.2", "dotenv": "^16.3.1", "msw": "^1.2.3", "react": "^18.2.0", @@ -28,6 +29,7 @@ "@storybook/react": "^7.0.26", "@storybook/react-webpack5": "^7.0.26", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^4.35.3", "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.2", "@types/react": "^18.2.14", @@ -56,6 +58,7 @@ "tsconfig-paths-webpack-plugin": "^4.0.1", "typescript": "^5.1.6", "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-merge": "^5.9.0", @@ -4185,6 +4188,12 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.23", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", + "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", + "dev": true + }, "node_modules/@remix-run/router": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.1.tgz", @@ -7034,21 +7043,37 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.8.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", + "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "dev": true, + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kentcdodds" + } + }, "node_modules/@tanstack/query-core": { - "version": "4.29.19", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.19.tgz", - "integrity": "sha512-uPe1DukeIpIHpQi6UzIgBcXsjjsDaLnc7hF+zLBKnaUlh7jFE/A+P8t4cU4VzKPMFB/C970n/9SxtpO5hmIRgw==", + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.35.3.tgz", + "integrity": "sha512-PS+WEjd9wzKTyNjjQymvcOe1yg8f3wYc6mD+vb6CKyZAKvu4sIJwryfqfBULITKCla7P9C4l5e9RXePHvZOZeQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "4.29.19", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.19.tgz", - "integrity": "sha512-XiTIOHHQ5Cw1WUlHaD4fmVUMhoWjuNJlAeJGq7eM4BraI5z7y8WkZO+NR8PSuRnQGblpuVdjClQbDFtwxTtTUw==", + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.35.3.tgz", + "integrity": "sha512-UgTPioip/rGG3EQilXfA2j4BJkhEQsR+KAbF+KIuvQ7j4MkgnTCJF01SfRpIRNtQTlEfz/+IL7+jP8WA8bFbsw==", "dependencies": { - "@tanstack/query-core": "4.29.19", + "@tanstack/query-core": "4.35.3", "use-sync-external-store": "^1.2.0" }, "funding": { @@ -7069,6 +7094,26 @@ } } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.35.3.tgz", + "integrity": "sha512-UvLT7qPzCuCZ3NfjwsOqDUVN84JvSOuW6ukrjZmSqgjPqVxD6ra/HUp1CEOatQY2TRvKCp8y1lTVu+trXM30fg==", + "dev": true, + "dependencies": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^4.35.3", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", @@ -9374,6 +9419,14 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -10147,6 +10200,21 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -11019,6 +11087,12 @@ "webpack": "^4 || ^5" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -13748,6 +13822,21 @@ "gunzip-maybe": "bin.js" } }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -14892,6 +14981,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", + "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "dev": true, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -18098,12 +18199,42 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "node_modules/lodash.invokemap": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", + "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.pullall": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.pullall/-/lodash.pullall-4.2.0.tgz", + "integrity": "sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg==", + "dev": true + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -18592,6 +18723,15 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -19162,6 +19302,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -20468,6 +20617,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==", + "dev": true + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -21122,6 +21277,20 @@ "semver": "bin/semver.js" } }, + "node_modules/sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21607,6 +21776,18 @@ "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==", "dev": true }, + "node_modules/superjson": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz", + "integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==", + "dev": true, + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -22089,6 +22270,15 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -22629,6 +22819,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -22776,6 +22971,97 @@ } } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz", + "integrity": "sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "is-plain-object": "^5.0.0", + "lodash.debounce": "^4.0.8", + "lodash.escape": "^4.0.1", + "lodash.flatten": "^4.4.0", + "lodash.invokemap": "^4.6.0", + "lodash.pullall": "^4.2.0", + "lodash.uniqby": "^4.7.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", @@ -26165,6 +26451,12 @@ } } }, + "@polka/url": { + "version": "1.0.0-next.23", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", + "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", + "dev": true + }, "@remix-run/router": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.1.tgz", @@ -28246,17 +28538,37 @@ "file-system-cache": "2.3.0" } }, + "@tanstack/match-sorter-utils": { + "version": "8.8.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", + "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "dev": true, + "requires": { + "remove-accents": "0.4.2" + } + }, "@tanstack/query-core": { - "version": "4.29.19", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.19.tgz", - "integrity": "sha512-uPe1DukeIpIHpQi6UzIgBcXsjjsDaLnc7hF+zLBKnaUlh7jFE/A+P8t4cU4VzKPMFB/C970n/9SxtpO5hmIRgw==" + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.35.3.tgz", + "integrity": "sha512-PS+WEjd9wzKTyNjjQymvcOe1yg8f3wYc6mD+vb6CKyZAKvu4sIJwryfqfBULITKCla7P9C4l5e9RXePHvZOZeQ==" }, "@tanstack/react-query": { - "version": "4.29.19", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.19.tgz", - "integrity": "sha512-XiTIOHHQ5Cw1WUlHaD4fmVUMhoWjuNJlAeJGq7eM4BraI5z7y8WkZO+NR8PSuRnQGblpuVdjClQbDFtwxTtTUw==", + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.35.3.tgz", + "integrity": "sha512-UgTPioip/rGG3EQilXfA2j4BJkhEQsR+KAbF+KIuvQ7j4MkgnTCJF01SfRpIRNtQTlEfz/+IL7+jP8WA8bFbsw==", "requires": { - "@tanstack/query-core": "4.29.19", + "@tanstack/query-core": "4.35.3", + "use-sync-external-store": "^1.2.0" + } + }, + "@tanstack/react-query-devtools": { + "version": "4.35.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.35.3.tgz", + "integrity": "sha512-UvLT7qPzCuCZ3NfjwsOqDUVN84JvSOuW6ukrjZmSqgjPqVxD6ra/HUp1CEOatQY2TRvKCp8y1lTVu+trXM30fg==", + "dev": true, + "requires": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", "use-sync-external-store": "^1.2.0" } }, @@ -30124,6 +30436,14 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "requires": { + "uzip": "0.20201231.0" + } + }, "browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -30681,6 +31001,15 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "requires": { + "is-what": "^4.1.8" + } + }, "copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -31340,6 +31669,12 @@ "dotenv-defaults": "^2.0.2" } }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -33335,6 +33670,15 @@ "through2": "^2.0.3" } }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "requires": { + "duplexer": "^0.1.2" + } + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -34120,6 +34464,12 @@ "get-intrinsic": "^1.1.1" } }, + "is-what": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", + "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "dev": true + }, "is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -36509,12 +36859,42 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, + "lodash.invokemap": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", + "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.pullall": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.pullall/-/lodash.pullall-4.2.0.tgz", + "integrity": "sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg==", + "dev": true + }, + "lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -36880,6 +37260,12 @@ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true }, + "mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -37290,6 +37676,12 @@ "is-wsl": "^2.2.0" } }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -38259,6 +38651,12 @@ "unist-util-visit": "^2.0.0" } }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==", + "dev": true + }, "renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -38768,6 +39166,17 @@ } } }, + "sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -39139,6 +39548,15 @@ "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==", "dev": true }, + "superjson": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz", + "integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==", + "dev": true, + "requires": { + "copy-anything": "^3.0.2" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -39510,6 +39928,12 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true + }, "tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -39910,6 +40334,11 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true }, + "uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -40065,6 +40494,64 @@ } } }, + "webpack-bundle-analyzer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz", + "integrity": "sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "is-plain-object": "^5.0.0", + "lodash.debounce": "^4.0.8", + "lodash.escape": "^4.0.1", + "lodash.flatten": "^4.4.0", + "lodash.invokemap": "^4.6.0", + "lodash.pullall": "^4.2.0", + "lodash.uniqby": "^4.7.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "dependencies": { + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true + }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "requires": {} + } + } + }, "webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index edfcc6404..b4dbd8852 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "license": "ISC", "scripts": { "dev": "webpack-dev-server --config webpack.dev.js --open --hot", + "https-dev": "webpack-dev-server --config webpack.https.js --open --hot", "prebuild": "cd src/utils && node generateSiteMap.js", "build": "webpack --config webpack.prod.js", "start": "webpack --config webpack.dev.js", @@ -16,7 +17,8 @@ "build-storybook": "storybook build", "test": "jest", "prepare": "cd .. && husky install frontend/.husky", - "mac-local-ip": "ifconfig | grep \"inet \" | grep -v 127.0.0.1" + "mac-local-ip": "ifconfig | grep \"inet \" | grep -v 127.0.0.1", + "check-bundle": "webpack --config webpack.analyzer.js" }, "dependencies": { "@tanstack/react-query": "^4.29.19", @@ -46,6 +48,7 @@ "@storybook/react": "^7.0.26", "@storybook/react-webpack5": "^7.0.26", "@storybook/testing-library": "^0.0.14-next.2", + "@tanstack/react-query-devtools": "^4.35.3", "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.2", "@types/react": "^18.2.14", @@ -74,6 +77,7 @@ "tsconfig-paths-webpack-plugin": "^4.0.1", "typescript": "^5.1.6", "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-merge": "^5.9.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index df02527b8..d1d17ac3e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -45,6 +45,10 @@ 보투게더 - VoTogether +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d7708033..efa2437e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Suspense } from 'react'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ThemeProvider } from 'styled-components'; import { AuthProvider } from '@hooks/context/auth'; @@ -37,6 +38,7 @@ const App = () => ( + ); diff --git a/frontend/src/api/post.ts b/frontend/src/api/post.ts index 87574912a..a44d2eeb6 100644 --- a/frontend/src/api/post.ts +++ b/frontend/src/api/post.ts @@ -19,6 +19,8 @@ export const transformPostResponse = (post: PostInfoResponse): PostInfo => { imageUrl: post.imageUrl, postId: post.postId, createTime: post.createdAt, + imageCount: post.imageCount, + commentCount: post.commentCount, title: post.title, voteInfo: { allPeopleCount: post.voteInfo.totalVoteCount, diff --git a/frontend/src/api/token.ts b/frontend/src/api/token.ts index 0bb33e9b2..fb896bb83 100644 --- a/frontend/src/api/token.ts +++ b/frontend/src/api/token.ts @@ -7,7 +7,10 @@ const BASE_URL = process.env.VOTOGETHER_BASE_URL ?? ''; export const postTokens = async (accessToken: string): Promise => { const response = await fetch(`${BASE_URL}/auth/silent-login`, { method: 'POST', - body: accessToken, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ accessToken }), credentials: 'include', }); diff --git a/frontend/src/api/userInfo.ts b/frontend/src/api/userInfo.ts index 38676654e..100e4c694 100644 --- a/frontend/src/api/userInfo.ts +++ b/frontend/src/api/userInfo.ts @@ -42,5 +42,5 @@ export const updateUserInfo = async (userInfo: UpdateUserInfoRequest) => { }; export const logoutUser = async () => { - await fetch('/auth/logout', { method: 'DELETE' }); + await fetch('/auth/logout', { method: 'DELETE', credentials: 'include' }); }; diff --git a/frontend/src/api/voteResult.ts b/frontend/src/api/voteResult.ts index e3960c0f5..b7400528f 100644 --- a/frontend/src/api/voteResult.ts +++ b/frontend/src/api/voteResult.ts @@ -1,11 +1,14 @@ -import { VoteResultResponse } from '@components/VoteStatistics/type'; +import { VoteResult, VoteResultResponse } from '@components/VoteStatistics/type'; +import { transVoteStatisticsFormat } from '@components/VoteStatistics/util'; import { getFetch } from '@utils/fetch'; const BASE_URL = process.env.VOTOGETHER_BASE_URL; -export const getPostStatistics = async (postId: number): Promise => { - return await getFetch(`${BASE_URL}/posts/${postId}/options`); +export const getPostStatistics = async (postId: number): Promise => { + const data = await getFetch(`${BASE_URL}/posts/${postId}/options`); + + return transVoteStatisticsFormat(data); }; export const getOptionStatistics = async ({ @@ -14,6 +17,10 @@ export const getOptionStatistics = async ({ }: { postId: number; optionId: number; -}): Promise => { - return await getFetch(`${BASE_URL}/posts/${postId}/options/${optionId}`); +}): Promise => { + const data = await getFetch( + `${BASE_URL}/posts/${postId}/options/${optionId}` + ); + + return transVoteStatisticsFormat(data); }; diff --git a/frontend/src/assets.d.ts b/frontend/src/assets.d.ts index 976cf508f..5c868fe39 100644 --- a/frontend/src/assets.d.ts +++ b/frontend/src/assets.d.ts @@ -7,3 +7,8 @@ declare module '*.png' { const content: any; export default content; } + +declare module '*.webp' { + const content: any; + export default content; +} diff --git a/frontend/src/assets/chain.svg b/frontend/src/assets/chain.svg new file mode 100644 index 000000000..e092f58e0 --- /dev/null +++ b/frontend/src/assets/chain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/comment.svg b/frontend/src/assets/comment.svg new file mode 100644 index 000000000..107efbaef --- /dev/null +++ b/frontend/src/assets/comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/first-rank.svg b/frontend/src/assets/first-rank.svg deleted file mode 100644 index 92513f745..000000000 --- a/frontend/src/assets/first-rank.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/first-rank.webp b/frontend/src/assets/first-rank.webp new file mode 100644 index 000000000..c36d524eb Binary files /dev/null and b/frontend/src/assets/first-rank.webp differ diff --git a/frontend/src/assets/kakao_login.svg b/frontend/src/assets/kakao_login.svg deleted file mode 100644 index cbdb3098f..000000000 --- a/frontend/src/assets/kakao_login.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/kakao_login.webp b/frontend/src/assets/kakao_login.webp new file mode 100644 index 000000000..67645f7b6 Binary files /dev/null and b/frontend/src/assets/kakao_login.webp differ diff --git a/frontend/src/assets/kakao_login_large.svg b/frontend/src/assets/kakao_login_large.svg deleted file mode 100644 index fc16cc083..000000000 --- a/frontend/src/assets/kakao_login_large.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/kakao_login_large.webp b/frontend/src/assets/kakao_login_large.webp new file mode 100644 index 000000000..555f1b325 Binary files /dev/null and b/frontend/src/assets/kakao_login_large.webp differ diff --git a/frontend/src/assets/kakao_login_medium_wide.svg b/frontend/src/assets/kakao_login_medium_wide.svg deleted file mode 100644 index cbdb3098f..000000000 --- a/frontend/src/assets/kakao_login_medium_wide.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/kakao_login_medium_wide.webp b/frontend/src/assets/kakao_login_medium_wide.webp new file mode 100644 index 000000000..c8f89dcc0 Binary files /dev/null and b/frontend/src/assets/kakao_login_medium_wide.webp differ diff --git a/frontend/src/assets/photo_black.svg b/frontend/src/assets/photo_black.svg new file mode 100644 index 000000000..e38e4cc1c --- /dev/null +++ b/frontend/src/assets/photo_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/ranking.png b/frontend/src/assets/ranking.png new file mode 100644 index 000000000..d918dff20 Binary files /dev/null and b/frontend/src/assets/ranking.png differ diff --git a/frontend/src/assets/ranking.svg b/frontend/src/assets/ranking.svg deleted file mode 100644 index 4b1b5f5cf..000000000 --- a/frontend/src/assets/ranking.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/second-rank.svg b/frontend/src/assets/second-rank.svg deleted file mode 100644 index c91f6f43a..000000000 --- a/frontend/src/assets/second-rank.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/second-rank.webp b/frontend/src/assets/second-rank.webp new file mode 100644 index 000000000..6f1784d1e Binary files /dev/null and b/frontend/src/assets/second-rank.webp differ diff --git a/frontend/src/assets/third-rank.svg b/frontend/src/assets/third-rank.svg deleted file mode 100644 index 6da147c62..000000000 --- a/frontend/src/assets/third-rank.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/third-rank.webp b/frontend/src/assets/third-rank.webp new file mode 100644 index 000000000..656a7cc5e Binary files /dev/null and b/frontend/src/assets/third-rank.webp differ diff --git a/frontend/src/assets/user.png b/frontend/src/assets/user.png new file mode 100644 index 000000000..cd531dcbb Binary files /dev/null and b/frontend/src/assets/user.png differ diff --git a/frontend/src/assets/user.svg b/frontend/src/assets/user.svg deleted file mode 100644 index 66ba4b665..000000000 --- a/frontend/src/assets/user.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/assets/votogether_home.png b/frontend/src/assets/votogether_home.png deleted file mode 100644 index 320c25aba..000000000 Binary files a/frontend/src/assets/votogether_home.png and /dev/null differ diff --git a/frontend/src/assets/votogether_home.webp b/frontend/src/assets/votogether_home.webp new file mode 100644 index 000000000..5905d58eb Binary files /dev/null and b/frontend/src/assets/votogether_home.webp differ diff --git a/frontend/src/assets/votogether_write.png b/frontend/src/assets/votogether_write.png deleted file mode 100644 index df6ff3d6f..000000000 Binary files a/frontend/src/assets/votogether_write.png and /dev/null differ diff --git a/frontend/src/assets/votogether_write.webp b/frontend/src/assets/votogether_write.webp new file mode 100644 index 000000000..516a4df07 Binary files /dev/null and b/frontend/src/assets/votogether_write.webp differ diff --git a/frontend/src/components/ChannelTalk/index.tsx b/frontend/src/components/ChannelTalk/index.tsx index 60ebb97c5..7cb1a03f0 100644 --- a/frontend/src/components/ChannelTalk/index.tsx +++ b/frontend/src/components/ChannelTalk/index.tsx @@ -79,9 +79,7 @@ class ChannelTalk { loadScript() { (function () { let w = window; - if (w.ChannelIO) { - return w.console.error('ChannelIO script included twice.'); - } + if (w.ChannelIO) return; let ch: IChannelIO = function () { ch.c?.(arguments); }; diff --git a/frontend/src/components/PostForm/ContentImageSection/index.tsx b/frontend/src/components/PostForm/ContentImageSection/index.tsx index a66c0e93f..611bc70f1 100644 --- a/frontend/src/components/PostForm/ContentImageSection/index.tsx +++ b/frontend/src/components/PostForm/ContentImageSection/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, MouseEvent, MutableRefObject } from 'react'; +import { ChangeEvent, MutableRefObject } from 'react'; import { Size } from '@type/style'; @@ -18,11 +18,6 @@ interface ContentImageSectionProps { export default function ContentImageSection({ contentImageHook, size }: ContentImageSectionProps) { const { contentImage, contentInputRef, removeImage, handleUploadImage } = contentImageHook; - const handleButtonClick = (e: MouseEvent) => { - e.preventDefault(); - contentInputRef.current && contentInputRef.current.click(); - }; - return ( <> {contentImage && ( @@ -35,12 +30,7 @@ export default function ContentImageSection({ contentImageHook, size }: ContentI )} { - + 본문에 사진 넣기 { const { mutate } = useCreatePost(); return ( <> - + ); }; @@ -70,7 +72,7 @@ export const OldPost = () => { const { mutate } = useEditPost(examplePostId); return ( <> - + ); }; diff --git a/frontend/src/components/PostForm/constants.ts b/frontend/src/components/PostForm/constants.ts index 595ecad57..225418018 100644 --- a/frontend/src/components/PostForm/constants.ts +++ b/frontend/src/components/PostForm/constants.ts @@ -1,5 +1,51 @@ -export type DeadlineOption = '10분' | '30분' | '1시간' | '6시간' | '1일'; +import { Time } from '@type/post'; -export const DEADLINE_OPTION: DeadlineOption[] = ['10분', '30분', '1시간', '6시간', '1일']; +export type DeadlineOptionName = '1일' | '3일' | '5일' | '7일' | '14일'; -export const MAX_FILE_SIZE = 1500000; +export interface DeadlineOptionInfo { + name: DeadlineOptionName; + time: Time; +} + +export const DEADLINE_OPTION: DeadlineOptionInfo[] = [ + { + name: '1일', + time: { + day: 1, + hour: 0, + minute: 0, + }, + }, + { + name: '3일', + time: { + day: 3, + hour: 0, + minute: 0, + }, + }, + { + name: '5일', + time: { + day: 5, + hour: 0, + minute: 0, + }, + }, + { + name: '7일', + time: { + day: 7, + hour: 0, + minute: 0, + }, + }, + { + name: '14일', + time: { + day: 14, + hour: 0, + minute: 0, + }, + }, +]; diff --git a/frontend/src/components/PostForm/index.tsx b/frontend/src/components/PostForm/index.tsx index 69ee2dad3..ee14e0b16 100644 --- a/frontend/src/components/PostForm/index.tsx +++ b/frontend/src/components/PostForm/index.tsx @@ -14,6 +14,7 @@ import { useWritingOption } from '@hooks/useWritingOption'; import ErrorBoundary from '@pages/ErrorBoundary'; +import HeaderTextButton from '@components/common/HeaderTextButton'; import Modal from '@components/common/Modal'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; import SquareButton from '@components/common/SquareButton'; @@ -27,30 +28,29 @@ import { POST_DEADLINE_POLICY, POST_TITLE_POLICY, } from '@constants/policyMessage'; -import { CATEGORY_COUNT_LIMIT, POST_CONTENT, POST_TITLE } from '@constants/post'; +import { CATEGORY_COUNT_LIMIT, MAX_DEADLINE, POST_CONTENT, POST_TITLE } from '@constants/post'; import { calculateDeadlineTime } from '@utils/post/calculateDeadlineTime'; import { checkWriter } from '@utils/post/checkWriter'; -import { - convertImageUrlToServerUrl, - convertServerUrlToImageUrl, -} from '@utils/post/convertImageUrlToServerUrl'; -import { addTimeToDate, formatTimeWithOption } from '@utils/post/formatTime'; +import { deleteOverlappingNewLine } from '@utils/post/deleteOverlappingNewLine'; +import { addTimeToDate } from '@utils/post/formatTime'; import { getDeadlineTime } from '@utils/post/getDeadlineTime'; import { getSelectedTimeOption } from '@utils/post/getSelectedTimeOption'; import { checkIrreplaceableTime } from '@utils/time'; import CategoryWrapper from './CategoryWrapper'; -import { DEADLINE_OPTION, DeadlineOption } from './constants'; +import { DEADLINE_OPTION, DeadlineOptionInfo, DeadlineOptionName } from './constants'; import ContentImagePart from './ContentImageSection'; import * as S from './style'; +import { checkValidationPost } from './validation'; interface PostFormProps extends HTMLAttributes { data?: PostInfo; mutate: UseMutateFunction; + isSubmitting: boolean; } -export default function PostForm({ data, mutate }: PostFormProps) { +export default function PostForm({ data, mutate, isSubmitting }: PostFormProps) { const { postId, title, @@ -64,20 +64,19 @@ export default function PostForm({ data, mutate }: PostFormProps) { } = data ?? {}; const navigate = useNavigate(); - const contentImageHook = useContentImage( - serverImageUrl && convertImageUrlToServerUrl(serverImageUrl) - ); + const contentImageHook = useContentImage(serverImageUrl); + const { handlePasteImage } = contentImageHook; const writingOptionHook = useWritingOption( serverVoteInfo?.options.map(option => ({ ...option, - imageUrl: option.imageUrl ? convertImageUrlToServerUrl(option.imageUrl) : '', + imageUrl: option.imageUrl ?? '', })) ); const { isToastOpen, openToast, toastMessage } = useToast(); - const [selectTimeOption, setSelectTimeOption] = useState( - getSelectedTimeOption(calculateDeadlineTime(createTime, deadline)) - ); + const [selectTimeOption, setSelectTimeOption] = useState< + DeadlineOptionName | '사용자지정' | null + >(getSelectedTimeOption(calculateDeadlineTime(createTime, deadline))); const { isOpen, openComponent, closeComponent } = useToggle(); const [time, setTime] = useState(calculateDeadlineTime(createTime, deadline)); const baseTime = createTime ? new Date(createTime) : new Date(); @@ -105,12 +104,12 @@ export default function PostForm({ data, mutate }: PostFormProps) { } = useText(content ?? ''); const multiSelectHook = useMultiSelect(categoryIds ?? [], CATEGORY_COUNT_LIMIT); - const handleDeadlineButtonClick = (option: DeadlineOption) => { - const targetTime = formatTimeWithOption(option); + const handleDeadlineButtonClick = (option: DeadlineOptionInfo) => { + const targetTime = option.time; if (data && checkIrreplaceableTime(targetTime, data.createTime)) return openToast('마감시간 지정 조건을 다시 확인해주세요.'); - setSelectTimeOption(option); + setSelectTimeOption(option.name); setTime(targetTime); }; @@ -133,66 +132,50 @@ export default function PostForm({ data, mutate }: PostFormProps) { e.preventDefault(); const formData = new FormData(); - const writingOptionList = writingOptionHook.optionList.map(({ text, imageUrl }, index) => { - return { content: text, imageUrl: convertServerUrlToImageUrl(imageUrl) }; - }); - - const imageUrlList = [ - convertServerUrlToImageUrl(contentImageHook.contentImage), - ...writingOptionList.map(option => option.imageUrl), - ]; - //예외처리 const { selectedOptionList } = multiSelectHook; - if (selectedOptionList.length < 1) return openToast('카테고리를 최소 1개 골라주세요.'); - if (selectedOptionList.length > 3) return openToast('카테고리를 최대 3개 골라주세요.'); - if (writingTitle.trim() === '') return openToast('제목은 필수로 입력해야 합니다.'); - if (writingContent.trim() === '') return openToast('내용은 필수로 입력해야 합니다.'); - if (writingOptionList.length < 2) return openToast('선택지는 최소 2개 입력해주세요.'); - if (writingOptionList.length > 5) return openToast('선택지는 최대 5개 입력할 수 있습니다.'); - if (writingOptionList.some(option => option.content.trim() === '')) - return openToast('선택지에 글을 입력해주세요.'); - if (Object.values(time).reduce((a, b) => a + b, 0) < 1) - return openToast('시간은 필수로 입력해야 합니다.'); + const errorMessage = checkValidationPost( + selectedOptionList, + writingTitle, + writingContent, + writingOptionHook.optionList, + time + ); + if (errorMessage) return openToast(errorMessage); + + const writingOptionList = writingOptionHook.optionList.map( + ({ id, isServerId, text, imageUrl }, index) => { + return { id, isServerId, content: text, imageUrl }; + } + ); if (e.target instanceof HTMLFormElement) { - const optionImageFileInputs = - e.target.querySelectorAll('input[type="file"]'); - const fileInputList: HTMLInputElement[] = [...optionImageFileInputs]; - const contentImageFileList: File[] = []; - const optionImageFileList: File[] = []; - fileInputList.forEach((item, index) => { + const imageFileInputs = e.target.querySelectorAll('input[type="file"]'); + const fileInputList = [...imageFileInputs]; + + selectedOptionList.forEach(categoryId => + formData.append('categoryIds', categoryId.id.toString()) + ); + formData.append('title', writingTitle); + formData.append('content', deleteOverlappingNewLine(writingContent)); + formData.append('imageUrl', contentImageHook.contentImage); + writingOptionList.forEach((option, index) => { + option.isServerId && formData.append(`postOptions[${index}].id`, option.id.toString()); + formData.append(`postOptions[${index}].content`, deleteOverlappingNewLine(option.content)); + formData.append(`postOptions[${index}].imageUrl`, option.imageUrl); + }); + formData.append('deadline', addTimeToDate(time, baseTime)); + + fileInputList.forEach((item: HTMLInputElement, index: number) => { if (!item.files) return; if (index === 0) { - //사진url이 ""거나 undefined이거나 기존 url과 동일한 url이면 true - !imageUrlList[index] || imageUrlList[index] === serverImageUrl - ? contentImageFileList.push(new File(['없는사진'], '없는사진.jpg')) - : contentImageFileList.push(item.files[0]); + item.files[0] && formData.append('imageFile', item.files[0]); } else { - //사진url이 ""거나 undefined이거나 기존 url과 동일한 url이면 true - !imageUrlList[index] || - imageUrlList[index] === serverVoteInfo?.options[index - 1].imageUrl - ? optionImageFileList.push(new File(['없는사진'], '없는사진.jpg')) - : optionImageFileList.push(item.files[0]); + item.files[0] && formData.append(`postOptions[${index - 1}].imageFile`, item.files[0]); } }); - contentImageFileList.map(file => formData.append('contentImages', file)); - optionImageFileList.map(file => formData.append('optionImages', file)); - - const updatedPostTexts = { - categoryIds: selectedOptionList.map(option => option.id), - title: writingTitle, - imageUrl: convertServerUrlToImageUrl(contentImageHook.contentImage), - content: writingContent, - postOptions: writingOptionList, - deadline: addTimeToDate(time, baseTime), - // 글 수정의 경우 작성시간을 기준으로 마감시간 옵션을 더한다. - // 마감시간 옵션을 선택 안했다면 기존의 마감 시간을 유지한다. - }; - formData.append('request', JSON.stringify(updatedPostTexts)); - mutate(formData); } }; @@ -203,10 +186,10 @@ export default function PostForm({ data, mutate }: PostFormProps) { <> - navigate('/')}>취소 - - 저장 - + navigate('/')}>취소 + + {isSubmitting ? '저장 중...' : '저장'} +
@@ -233,6 +216,7 @@ export default function PostForm({ data, mutate }: PostFormProps) { placeholder={CONTENT_PLACEHOLDER} maxLength={POST_CONTENT.MAX_LENGTH} minLength={POST_CONTENT.MIN_LENGTH} + onPaste={handlePasteImage} required /> @@ -260,14 +244,14 @@ export default function PostForm({ data, mutate }: PostFormProps) { {getDeadlineTime({ hour: time.hour, day: time.day, minute: time.minute })} {data && ( - 현재 시간으로부터 글 작성일({createTime})로부터 3일 이내 ( - {addTimeToDate({ day: 3, hour: 0, minute: 0 }, baseTime)})까지만 선택 + 현재 시간으로부터 글 작성일({createTime})로부터 {MAX_DEADLINE}일 이내 ( + {addTimeToDate({ day: MAX_DEADLINE, hour: 0, minute: 0 }, baseTime)})까지만 선택 가능합니다. )} {data && ( - * 작성일시로부터 마감시간이 계산됩니다.{' '} + * 작성일시로부터 마감시간이 계산됩니다. )} {data && ( @@ -277,13 +261,13 @@ export default function PostForm({ data, mutate }: PostFormProps) { {DEADLINE_OPTION.map(option => ( handleDeadlineButtonClick(option)} - theme={selectTimeOption === option ? 'fill' : 'blank'} + theme={selectTimeOption === option.name ? 'fill' : 'blank'} > - {option} + {option.name} ))} { @@ -300,9 +284,10 @@ export default function PostForm({ data, mutate }: PostFormProps) { 저장 diff --git a/frontend/src/components/PostForm/style.ts b/frontend/src/components/PostForm/style.ts index 5c46bca19..811496a3a 100644 --- a/frontend/src/components/PostForm/style.ts +++ b/frontend/src/components/PostForm/style.ts @@ -26,10 +26,16 @@ export const HeaderButton = styled.button` export const Wrapper = styled.div` display: grid; grid-template-columns: 1fr; + justify-items: center; + justify-content: center; gap: 20px; padding: 70px 10px 20px 10px; + & > * { + width: 100%; + } + @media (min-width: ${theme.breakpoint.sm}) { grid-template-columns: 2fr 1fr; gap: 30px; diff --git a/frontend/src/components/PostForm/validation.ts b/frontend/src/components/PostForm/validation.ts new file mode 100644 index 000000000..a56016a03 --- /dev/null +++ b/frontend/src/components/PostForm/validation.ts @@ -0,0 +1,27 @@ +import { Time } from '@type/post'; + +import { WritingVoteOptionType } from '@hooks/useWritingOption'; + +import { Option } from '@components/common/MultiSelect/types'; + +export const checkValidationPost = ( + categoryList: Option[], + title: string, + content: string, + optionList: WritingVoteOptionType[], + time: Time +) => { + if (categoryList.length < 1) return '카테고리를 최소 1개 골라주세요.'; + if (categoryList.length > 3) return '카테고리를 최대 3개 골라주세요.'; + + if (title.trim() === '') return '제목은 필수로 입력해야 합니다.'; + + if (content.trim() === '') return '내용은 필수로 입력해야 합니다.'; + + if (optionList.length < 2) return '선택지는 최소 2개 입력해주세요.'; + if (optionList.length > 5) return '선택지는 최대 5개 입력할 수 있습니다.'; + if (optionList.some(option => option.text.trim() === '')) return '선택지에 글을 입력해주세요.'; + + const isTimeOptionZero = Object.values(time).reduce((a, b) => a + b, 0) < 1; + if (isTimeOptionZero) return '시간은 필수로 입력해야 합니다.'; +}; diff --git a/frontend/src/components/ReportModal/ReportModal.stories.tsx b/frontend/src/components/ReportModal/ReportModal.stories.tsx index 88f08690f..7b8ba17bb 100644 --- a/frontend/src/components/ReportModal/ReportModal.stories.tsx +++ b/frontend/src/components/ReportModal/ReportModal.stories.tsx @@ -11,18 +11,33 @@ type Story = StoryObj; export const Nickname: Story = { render: () => ( - {}} handleReportClick={() => {}} /> + {}} + handleReportClick={() => {}} + isReportLoading={false} + /> ), }; export const Comment: Story = { render: () => ( - {}} handleReportClick={() => {}} /> + {}} + handleReportClick={() => {}} + isReportLoading={false} + /> ), }; export const Post: Story = { render: () => ( - {}} handleReportClick={() => {}} /> + {}} + handleReportClick={() => {}} + isReportLoading={false} + /> ), }; diff --git a/frontend/src/components/ReportModal/index.tsx b/frontend/src/components/ReportModal/index.tsx index 7b1a22862..2f3d9232f 100644 --- a/frontend/src/components/ReportModal/index.tsx +++ b/frontend/src/components/ReportModal/index.tsx @@ -11,19 +11,21 @@ interface UserReportModalProps { reportType: ReportType; handleCancelClick: () => void; handleReportClick: (reason: string) => void; + isReportLoading: boolean; } export default function ReportModal({ reportType, handleCancelClick, handleReportClick, + isReportLoading, }: UserReportModalProps) { const { name, reportMessageList } = REPORT_TYPE[reportType]; const defaultReportMessage = Object.keys(reportMessageList)[0]; const { selectedOption, handleOptionChange } = useSelect(defaultReportMessage); const handlePrimaryButtonClick = () => { - handleReportClick(selectedOption); + !isReportLoading && handleReportClick(selectedOption); handleCancelClick(); }; diff --git a/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx b/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx index acabbe502..92f496f1d 100644 --- a/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx +++ b/frontend/src/components/VoteStatistics/VoteStatistics.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { MOCK_VOTE_RESULT } from '@mocks/mockData/voteResult'; +import { transVoteStatisticsFormat } from './util'; + import VoteStatistics from '.'; const meta: Meta = { @@ -12,13 +14,19 @@ export default meta; type Story = StoryObj; export const SizeSm: Story = { - render: () => , + render: () => ( + + ), }; export const SizeMd: Story = { - render: () => , + render: () => ( + + ), }; export const SizeLg: Story = { - render: () => , + render: () => ( + + ), }; diff --git a/frontend/src/components/VoteStatistics/index.tsx b/frontend/src/components/VoteStatistics/index.tsx index e54db51bb..0cbfcebe3 100644 --- a/frontend/src/components/VoteStatistics/index.tsx +++ b/frontend/src/components/VoteStatistics/index.tsx @@ -5,8 +5,7 @@ import { Size } from '@type/style'; import OneLineGraph from './OneLineGraph'; import * as S from './style'; import TwoLineGraph from './TwoLineGraph'; -import { VoteResultResponse } from './type'; -import { transVoteStatisticsFormat } from './util'; +import { VoteResult } from './type'; interface RadioMode { all: string; @@ -14,7 +13,7 @@ interface RadioMode { } export interface VoteStatisticsProps { - voteResultResponse: VoteResultResponse; + voteResult: VoteResult; size: Size; } @@ -25,13 +24,11 @@ const radioMode: RadioMode = { type RadioCategory = keyof RadioMode; -export default function VoteStatistics({ voteResultResponse, size }: VoteStatisticsProps) { +export default function VoteStatistics({ voteResult, size }: VoteStatisticsProps) { const [currentRadioMode, setCurrentRadioMode] = useState('all'); const radioModeKey = Object.keys(radioMode) as RadioCategory[]; - const voteResult = transVoteStatisticsFormat(voteResultResponse); - const changeMode = (e: MouseEvent) => { const target = e.target as HTMLInputElement; const targetCategory = target.value as RadioCategory; diff --git a/frontend/src/components/comment/CommentList/CommentItem/index.tsx b/frontend/src/components/comment/CommentList/CommentItem/index.tsx index fd9049ee0..94d894f81 100644 --- a/frontend/src/components/comment/CommentList/CommentItem/index.tsx +++ b/frontend/src/components/comment/CommentList/CommentItem/index.tsx @@ -15,6 +15,8 @@ import DeleteModal from '@components/common/DeleteModal'; import Toast from '@components/common/Toast'; import ReportModal from '@components/ReportModal'; +import { linkifyText } from '@utils/post/formatTextLink'; + import ellipsis from '@assets/ellipsis-horizontal.svg'; import { COMMENT_ACTION, COMMENT_MENU, COMMENT_USER, COMMENT_USER_MENU } from '../constants'; @@ -22,13 +24,15 @@ import { type CommentAction, type CommentUser } from '../types'; import CommentMenu from './CommentMenu'; import * as S from './style'; - interface CommentItemProps { comment: Comment; userType: CommentUser; } export default function CommentItem({ comment, userType }: CommentItemProps) { + const [isReportCommentLoading, setIsReportCommentLoading] = useState(false); + const [isReportNicknameLoading, setIsReportNicknameLoading] = useState(false); + const { isOpen, toggleComponent, closeComponent } = useToggle(); const { isToastOpen, openToast, toastMessage } = useToast(); const { id, member, content, createdAt, isEdit } = comment; @@ -37,7 +41,7 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { const params = useParams() as { postId: string }; const postId = Number(params.postId); - const { mutate, isError, error } = useDeleteComment(postId, id); + const { mutate, isError, error, isLoading: isCommentDeleting } = useDeleteComment(postId, id); const handleMenuClick = (menu: CommentAction) => { closeComponent(); @@ -46,6 +50,7 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { const handleCommentReportClick = async (reason: string) => { const reportData: ReportRequest = { type: 'COMMENT', id, reason }; + setIsReportCommentLoading(true); await reportContent(reportData) .then(res => { @@ -58,11 +63,15 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { return; } openToast('댓글 신고가 실패했습니다.'); + }) + .finally(() => { + setIsReportCommentLoading(false); }); }; const handleNicknameReportClick = async (reason: string) => { const reportData: ReportRequest = { type: 'NICKNAME', id: member.id, reason }; + setIsReportNicknameLoading(true); await reportContent(reportData) .then(res => { @@ -75,6 +84,9 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { return; } openToast('작성자 닉네임 신고가 실패했습니다.'); + }) + .finally(() => { + setIsReportNicknameLoading(false); }); }; @@ -139,13 +151,14 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { /> ) : ( - {content} + {linkifyText(content)} )} {action === COMMENT_ACTION.DELETE && ( )} {action === COMMENT_ACTION.USER_REPORT && ( @@ -153,6 +166,7 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { reportType="NICKNAME" handleReportClick={handleNicknameReportClick} handleCancelClick={handleCancelClick} + isReportLoading={isReportNicknameLoading} /> )} {action === COMMENT_ACTION.COMMENT_REPORT && ( @@ -160,6 +174,7 @@ export default function CommentItem({ comment, userType }: CommentItemProps) { reportType="COMMENT" handleReportClick={handleCommentReportClick} handleCancelClick={handleCancelClick} + isReportLoading={isReportCommentLoading} /> )} {isToastOpen && ( diff --git a/frontend/src/components/comment/CommentList/CommentItem/style.ts b/frontend/src/components/comment/CommentList/CommentItem/style.ts index 38b63cb28..a5e9a1806 100644 --- a/frontend/src/components/comment/CommentList/CommentItem/style.ts +++ b/frontend/src/components/comment/CommentList/CommentItem/style.ts @@ -59,6 +59,8 @@ export const SubTitle = styled.span` export const MenuWrapper = styled.div` position: absolute; right: 0%; + + z-index: ${theme.zIndex.modal}; `; export const Description = styled.p` diff --git a/frontend/src/components/comment/CommentList/CommentLoginSection/index.tsx b/frontend/src/components/comment/CommentList/CommentLoginSection/index.tsx index 92916fd84..68dbe51d2 100644 --- a/frontend/src/components/comment/CommentList/CommentLoginSection/index.tsx +++ b/frontend/src/components/comment/CommentList/CommentLoginSection/index.tsx @@ -1,6 +1,6 @@ import { PATH } from '@constants/path'; -import kakaoLogin from '@assets/kakao_login_large.svg'; +import kakaoLogin from '@assets/kakao_login_large.webp'; import * as S from './style'; diff --git a/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx b/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx index dc0a66553..1f0a25e39 100644 --- a/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx +++ b/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx @@ -13,6 +13,8 @@ import Toast from '@components/common/Toast'; import { COMMENT } from '@constants/comment'; +import { deleteOverlappingNewLine } from '@utils/post/deleteOverlappingNewLine'; + import * as S from './style'; interface CommentTextFormProps { commentId: number; @@ -25,7 +27,12 @@ export default function CommentTextForm({ initialComment, handleCancelClick, }: CommentTextFormProps) { - const { text: content, handleTextChange, resetText } = useText(initialComment.content); + const { + text: content, + handleTextChange, + resetText, + addText: addContent, + } = useText(initialComment.content); const { isToastOpen, openToast, toastMessage } = useToast(); const params = useParams() as { postId: string }; @@ -38,21 +45,27 @@ export default function CommentTextForm({ isSuccess: isCreateSuccess, isError: isCreateError, error: createError, + isLoading: createLoading, } = useCreateComment(postId); const { mutate: editComment, isSuccess: isEditSuccess, isError: isEditError, error: editError, + isLoading: editLoading, } = useEditComment(postId, commentId); - const updateComment = isEdit - ? () => { - editComment({ ...initialComment, content }); - } - : () => { - createComment({ content }); - }; + const handleUpdateComment = () => { + if (content.trim() === '') { + openToast('댓글에 내용을 입력해주세요.'); + return; + } + if (isEdit) { + editComment({ ...initialComment, content: deleteOverlappingNewLine(content) }); + return; + } + createComment({ content: deleteOverlappingNewLine(content) }); + }; useEffect(() => { isCreateSuccess && resetText(); @@ -63,11 +76,19 @@ export default function CommentTextForm({ }, [isEditSuccess]); useEffect(() => { - isCreateError && createError instanceof Error && openToast(createError.message); + if (isCreateError && createError instanceof Error) { + const errorResponse = JSON.parse(createError.message); + openToast(errorResponse.message); + return; + } }, [isCreateError, createError]); useEffect(() => { - isEditError && editError instanceof Error && openToast(editError.message); + if (isEditError && editError instanceof Error) { + const errorResponse = JSON.parse(editError.message); + openToast(errorResponse.message); + return; + } }, [isEditError, editError]); return ( @@ -75,6 +96,7 @@ export default function CommentTextForm({ ) => handleTextChange(e, COMMENT)} /> @@ -92,10 +114,21 @@ export default function CommentTextForm({ )} updateComment()} + aria-label="댓글에 링크 넣기" + onClick={() => addContent('[[괄호 안에 링크 작성]] ')} theme="blank" type="button" + > + 링크 넣기 + + + + 저장 diff --git a/frontend/src/components/common/AppInstallPrompt/BookMarkPrompt/style.ts b/frontend/src/components/common/AppInstallPrompt/BookMarkPrompt/style.ts index 327c9db5f..09b08b8bf 100644 --- a/frontend/src/components/common/AppInstallPrompt/BookMarkPrompt/style.ts +++ b/frontend/src/components/common/AppInstallPrompt/BookMarkPrompt/style.ts @@ -18,6 +18,14 @@ export const Container = styled.div` background-color: white; z-index: ${theme.zIndex.modal}; + + @media (max-width: 375px) { + margin-top: 30px; + } + + @media (max-width: 375px) and (min-width: 280px) { + margin-top: 0; + } `; export const Content = styled.div` @@ -34,6 +42,10 @@ export const Header = styled.div` justify-content: space-between; margin-bottom: 50px; + + @media (max-width: 376px) { + margin-bottom: 20px; + } `; export const LogoImage = styled.img` @@ -41,6 +53,11 @@ export const LogoImage = styled.img` width: 80px; height: 80px; + + @media (max-width: 375px) and (min-width: 280px) { + width: 50px; + height: 50px; + } `; export const HeaderContent = styled.div` @@ -66,6 +83,10 @@ export const Title = styled.span` export const Description = styled.p` font-size: 1.6rem; font-weight: 700; + + @media (max-width: 375px) and (min-width: 280px) { + font-size: 1.4rem; + } `; export const CancelButton = styled.button` @@ -81,6 +102,11 @@ export const CancelButton = styled.button` export const IconImage = styled.img` width: 24px; height: 24px; + + @media (max-width: 375px) and (min-width: 280px) { + width: 18px; + height: 18px; + } `; export const DescriptionWrapper = styled.div` diff --git a/frontend/src/components/common/AppInstallPrompt/index.tsx b/frontend/src/components/common/AppInstallPrompt/index.tsx index 2a8044071..f31e3de87 100644 --- a/frontend/src/components/common/AppInstallPrompt/index.tsx +++ b/frontend/src/components/common/AppInstallPrompt/index.tsx @@ -1,5 +1,7 @@ import { Fragment, useEffect, useState } from 'react'; +import ChannelTalk from '@components/ChannelTalk'; + import { getCookie, setCookie } from '@utils/cookie'; import { BeforeInstallPromptEvent } from '../../../../window'; @@ -11,6 +13,8 @@ const isBookMarkPromptActive = () => { const isActive = JSON.parse(getCookie().isAppInstallVisible || 'true'); if (isActive) { + ChannelTalk.hideChannelButton(); + return true; } @@ -38,6 +42,8 @@ export default function AppInstallPrompt() { setCookie({ key: 'isAppInstallVisible', value: 'false', maxAge: 7 * 24 * 60 * 60 }); setBookMarkPrompt(null); setDeferredPrompt(null); + + ChannelTalk.showChannelButton(); }; const handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) => { diff --git a/frontend/src/components/common/Banner/index.tsx b/frontend/src/components/common/Banner/index.tsx index acd504d2f..74a01ae5b 100644 --- a/frontend/src/components/common/Banner/index.tsx +++ b/frontend/src/components/common/Banner/index.tsx @@ -17,7 +17,7 @@ export default function Banner({ title, content, handleClose, path }: BannerProp {content} - 자세히 + 보러가기 ); diff --git a/frontend/src/components/common/Dashboard/GuestProfile/index.tsx b/frontend/src/components/common/Dashboard/GuestProfile/index.tsx index 9e1e15663..22b5fd926 100644 --- a/frontend/src/components/common/Dashboard/GuestProfile/index.tsx +++ b/frontend/src/components/common/Dashboard/GuestProfile/index.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import { BASE_PATH } from '@constants/path'; -import kakaoLogin from '@assets/kakao_login.svg'; +import kakaoLogin from '@assets/kakao_login.webp'; import * as S from './style'; diff --git a/frontend/src/components/common/Dashboard/UserProfile/style.ts b/frontend/src/components/common/Dashboard/UserProfile/style.ts index ff3c04804..a4a52684f 100644 --- a/frontend/src/components/common/Dashboard/UserProfile/style.ts +++ b/frontend/src/components/common/Dashboard/UserProfile/style.ts @@ -29,13 +29,13 @@ export const TextCardLink = styled(Link)` display: flex; flex-direction: column; - text-decoration: none; - color: initial; `; export const TextCardTitle = styled.span` font: var(--text-caption); + text-decoration: underline; + text-underline-position: under; `; export const TextCardContent = styled.span` diff --git a/frontend/src/components/common/DeleteModal/DeleteModal.stories.tsx b/frontend/src/components/common/DeleteModal/DeleteModal.stories.tsx index 01e008e72..20b027a88 100644 --- a/frontend/src/components/common/DeleteModal/DeleteModal.stories.tsx +++ b/frontend/src/components/common/DeleteModal/DeleteModal.stories.tsx @@ -11,12 +11,22 @@ type Story = StoryObj; export const Post: Story = { render: () => ( - {}} handleDeleteClick={() => {}} /> + {}} + handleDeleteClick={() => {}} + isDeleting={false} + /> ), }; export const User: Story = { render: () => ( - {}} handleDeleteClick={() => {}} /> + {}} + handleDeleteClick={() => {}} + isDeleting={false} + /> ), }; diff --git a/frontend/src/components/common/DeleteModal/index.tsx b/frontend/src/components/common/DeleteModal/index.tsx index 5d8dc51d1..a55f1c896 100644 --- a/frontend/src/components/common/DeleteModal/index.tsx +++ b/frontend/src/components/common/DeleteModal/index.tsx @@ -14,15 +14,17 @@ interface DeleteModalProps { target: TargetForDelete; handleCancelClick: () => void; handleDeleteClick: () => void; + isDeleting: boolean; } export default function DeleteModal({ target, handleCancelClick, handleDeleteClick, + isDeleting, }: DeleteModalProps) { const handlePrimaryButtonClick = () => { - handleDeleteClick(); + !isDeleting && handleDeleteClick(); handleCancelClick(); }; diff --git a/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx b/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx index 3b77283d2..5e6ea822b 100644 --- a/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx +++ b/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx @@ -10,5 +10,5 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: () => {}} />, + render: () => , }; diff --git a/frontend/src/components/common/ErrorMessage/index.tsx b/frontend/src/components/common/ErrorMessage/index.tsx index 69196e750..94800b4d0 100644 --- a/frontend/src/components/common/ErrorMessage/index.tsx +++ b/frontend/src/components/common/ErrorMessage/index.tsx @@ -1,21 +1,55 @@ -// import IconButton from '../IconButton'; -// import SquareButton from '../SquareButton'; +import { useNavigate } from 'react-router-dom'; + +import IconButton from '../IconButton'; +import LogoButton from '../LogoButton'; +import NarrowTemplateHeader from '../NarrowTemplateHeader'; +import SquareButton from '../SquareButton'; import * as S from './style'; -export default function ErrorMessage({ errorHandler }: { errorHandler?: () => void }) { +export default function ErrorMessage() { + const navigate = useNavigate(); + return ( - - ⚠ 잠시 후 다시 시도해주세요. - 요청하신 데이터를 불러오는데 실패했습니다. - {/* - - - - 다시 시도 - - - */} - + <> + + + { + navigate(-1); + }} + /> + + + + 요청하신 데이터를 불러오는데 실패했습니다. + + + 오류가 지속되는 경우 votogether2023@gmail.com 로 문의해주세요. + + + + { + navigate('/'); + }} + > + 홈으로 가기 + + + + window.location.reload()} + aria-label="다시 시도" + theme="blank" + > + 다시 시도 + + + + + ); } diff --git a/frontend/src/components/common/ErrorMessage/style.ts b/frontend/src/components/common/ErrorMessage/style.ts index fae76a2b0..3ddc24393 100644 --- a/frontend/src/components/common/ErrorMessage/style.ts +++ b/frontend/src/components/common/ErrorMessage/style.ts @@ -2,14 +2,28 @@ import { styled } from 'styled-components'; import { theme } from '@styles/theme'; +export const HeaderContainer = styled.header` + position: fixed; + width: 100%; + top: 0; + + z-index: ${theme.zIndex.header}; + + @media (min-width: ${theme.breakpoint.sm}) { + display: none; + } +`; + export const Wrapper = styled.div` display: flex; flex-direction: column; justify-content: center; align-items: center; - gap: 10px; + gap: 20px; position: relative; + + margin: 70px 10px 20px 10px; `; export const HeaderWrapper = styled.div` @@ -26,20 +40,26 @@ export const HeaderWrapper = styled.div` export const Title = styled.h1` width: 90%; - margin-top: 60px; + margin-top: 40px; + + color: rgba(0, 0, 0, 0.7); font-size: 20px; font-weight: bold; text-align: center; + word-break: keep-all; `; export const Description = styled.p` width: 90%; - margin: 20px 0; + margin-bottom: 50px; + + color: gray; font: var(--text-body); text-align: center; + word-break: keep-all; `; export const Direction = styled.div` @@ -51,17 +71,42 @@ export const Direction = styled.div` `; export const RetryText = styled.p` - display: flex; - justify-content: space-around; - gap: 10px; - - padding: 12px; - font: var(--text-body); font-weight: bold; `; export const ButtonWrapper = styled.div` - width: 120px; + width: 140px; height: 50px; `; + +export const Text = styled.p` + width: 90%; + + color: gray; + + font: var(--text-body); + text-align: center; +`; + +export const Image = styled.img` + width: 24px; + height: 24px; + + position: relative; + top: 2px; + + margin-right: 4px; +`; + +export const RetryWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +export const ButtonContainer = styled.div` + display: flex; + + gap: 20px; +`; diff --git a/frontend/src/components/common/HeaderTextButton/index.tsx b/frontend/src/components/common/HeaderTextButton/index.tsx index 6d5c0c73b..b6ae85132 100644 --- a/frontend/src/components/common/HeaderTextButton/index.tsx +++ b/frontend/src/components/common/HeaderTextButton/index.tsx @@ -4,11 +4,20 @@ import * as S from './style'; interface HeaderTextButtonProps extends ButtonHTMLAttributes { children: string; + isLoading?: boolean; } /* 헤더에 포함되어 취소, 확인, 신고 등 사용될 버튼 * props로 s/m/l 크기를 받음 */ -export default function HeaderTextButton({ children, ...rest }: HeaderTextButtonProps) { - return {children}; +export default function HeaderTextButton({ + children, + isLoading = false, + ...rest +}: HeaderTextButtonProps) { + return ( + + {children} + + ); } diff --git a/frontend/src/components/common/HeaderTextButton/style.ts b/frontend/src/components/common/HeaderTextButton/style.ts index d58bc9d15..2da451ee5 100644 --- a/frontend/src/components/common/HeaderTextButton/style.ts +++ b/frontend/src/components/common/HeaderTextButton/style.ts @@ -1,11 +1,11 @@ import { styled } from 'styled-components'; -export const Button = styled.button` +export const Button = styled.button<{ $isLoading: boolean }>` background-color: rgba(0, 0, 0, 0); - color: var(--white); + color: ${props => (props.$isLoading ? 'gray' : 'var(--white)')}; font: var(--text-caption); - font-weight: 500; + font-weight: ${props => (props.$isLoading ? 600 : 500)}; cursor: pointer; `; diff --git a/frontend/src/components/common/IconButton/index.tsx b/frontend/src/components/common/IconButton/index.tsx index 379e669dd..012a64cc1 100644 --- a/frontend/src/components/common/IconButton/index.tsx +++ b/frontend/src/components/common/IconButton/index.tsx @@ -2,10 +2,10 @@ import { ButtonHTMLAttributes } from 'react'; import backIcon from '@assets/back.svg'; import categoryIcon from '@assets/category.svg'; -import ranking from '@assets/ranking.svg'; +import ranking from '@assets/ranking.png'; import retryIcon from '@assets/retry.svg'; import searchIcon from '@assets/search_white.svg'; -import userInfo from '@assets/user.svg'; +import userInfo from '@assets/user.png'; import * as S from './style'; diff --git a/frontend/src/components/common/InputNSubmitButton/index.tsx b/frontend/src/components/common/InputNSubmitButton/index.tsx new file mode 100644 index 000000000..e374e997c --- /dev/null +++ b/frontend/src/components/common/InputNSubmitButton/index.tsx @@ -0,0 +1,44 @@ +import React, { ChangeEvent } from 'react'; + +import { useText } from '@hooks/useText'; + +import SquareButton from '@components/common/SquareButton'; + +import { TextLimit } from '@constants/user'; + +import * as S from './style'; + +interface InputNSubmitButtonProps { + handleSubmit: (newText: string) => void; + limitText: TextLimit; + initText?: string; + ariaLabel?: string; +} + +export default function InputNSubmitButton({ + handleSubmit, + limitText, + initText = '', + ariaLabel = '정보', +}: InputNSubmitButtonProps) { + const { text, handleTextChange } = useText(initText); + + return ( + + ) => handleTextChange(e, limitText)} + placeholder={`새로운 ${ariaLabel}을 입력해주세요`} + /> + + handleSubmit(text)} + > + 변경 + + + + ); +} diff --git a/frontend/src/components/common/InputNSubmitButton/style.ts b/frontend/src/components/common/InputNSubmitButton/style.ts new file mode 100644 index 000000000..77a134d66 --- /dev/null +++ b/frontend/src/components/common/InputNSubmitButton/style.ts @@ -0,0 +1,18 @@ +import { styled } from 'styled-components'; + +export const InputWrapper = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; + +export const Input = styled.input` + width: 80%; + border: 1px solid #f2f2f2; + padding: 20px; +`; + +export const ButtonWrapper = styled.div` + width: 90px; + height: 50px; +`; diff --git a/frontend/src/components/common/Layout/index.tsx b/frontend/src/components/common/Layout/index.tsx index 3b229f28d..5a02db83f 100644 --- a/frontend/src/components/common/Layout/index.tsx +++ b/frontend/src/components/common/Layout/index.tsx @@ -1,5 +1,6 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useEffect } from 'react'; +import ChannelTalk from '@components/ChannelTalk'; import Dashboard from '@components/common/Dashboard'; import WideHeader from '@components/common/WideHeader'; @@ -7,9 +8,18 @@ import * as S from './style'; interface LayoutProps extends PropsWithChildren { isSidebarVisible: boolean; + isChannelTalkVisible?: boolean; } -export default function Layout({ children, isSidebarVisible }: LayoutProps) { +export default function Layout({ + children, + isSidebarVisible, + isChannelTalkVisible = true, +}: LayoutProps) { + useEffect(() => { + isChannelTalkVisible ? ChannelTalk.showChannelButton() : ChannelTalk.hideChannelButton(); + }, [isChannelTalkVisible]); + return ( diff --git a/frontend/src/components/common/Modal/Modal.stories.tsx b/frontend/src/components/common/Modal/Modal.stories.tsx index 2a4870dbf..5d884f9a5 100644 --- a/frontend/src/components/common/Modal/Modal.stories.tsx +++ b/frontend/src/components/common/Modal/Modal.stories.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'; import { styled } from 'styled-components'; +import { MAX_DEADLINE } from '@constants/post'; + import SquareButton from '../SquareButton'; import TimePickerOptionList from '../TimePickerOptionList'; @@ -181,7 +183,7 @@ export const WithTimePicker = () => { X - 최대 3일을 넘을 수 없습니다. + 최대 {MAX_DEADLINE}일을 넘을 수 없습니다. diff --git a/frontend/src/components/common/Post/index.tsx b/frontend/src/components/common/Post/index.tsx index fb4f2de06..f6d2ea909 100644 --- a/frontend/src/components/common/Post/index.tsx +++ b/frontend/src/components/common/Post/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { memo, useContext, useEffect } from 'react'; import { PostInfo } from '@type/post'; @@ -12,11 +12,12 @@ import WrittenVoteOptionList from '@components/optionList/WrittenVoteOptionList' import { PATH } from '@constants/path'; import { POST } from '@constants/vote'; -import { convertImageUrlToServerUrl } from '@utils/post/convertImageUrlToServerUrl'; -import { linkifyText } from '@utils/post/formatContentLink'; +import { linkifyText } from '@utils/post/formatTextLink'; + import { checkClosedPost, convertTimeToWord } from '@utils/time'; -import photoIcon from '@assets/photo_white.svg'; +import commentIcon from '@assets/comment.svg'; +import photoIcon from '@assets/photo_black.svg'; import Toast from '../Toast'; @@ -27,21 +28,32 @@ interface PostProps { isPreview: boolean; } -export default function Post({ postInfo, isPreview }: PostProps) { - const { postId, category, imageUrl, title, writer, createTime, deadline, content, voteInfo } = - postInfo; +export default memo(function Post({ postInfo, isPreview }: PostProps) { + const { + postId, + category, + imageUrl, + title, + writer, + createTime, + deadline, + content, + voteInfo, + imageCount, + commentCount, + } = postInfo; const { loggedInfo } = useContext(AuthContext); const { isToastOpen, openToast, toastMessage } = useToast(); const { mutate: createVote, - isError: isCreateError, - error: createError, + isError: isCreateVoteError, + error: createVoteError, } = useCreateVote({ isPreview, postId }); const { mutate: editVote, - isError: isEditError, - error: editError, + isError: isEditVoteError, + error: editVoteError, } = useEditVote({ isPreview, postId }); const isActive = !checkClosedPost(deadline); @@ -79,22 +91,20 @@ export default function Post({ postInfo, isPreview }: PostProps) { }; useEffect(() => { - if (isCreateError && createError instanceof Error) { - openToast(createError.message); + if (isCreateVoteError && createVoteError instanceof Error) { + const errorResponse = JSON.parse(createVoteError.message); + openToast(errorResponse.message); + return; } - }, [isCreateError, createError]); + }, [isCreateVoteError, createVoteError]); useEffect(() => { - if (isEditError && editError instanceof Error) { - openToast(editError.message); + if (isEditVoteError && editVoteError instanceof Error) { + const errorResponse = JSON.parse(editVoteError.message); + openToast(errorResponse.message); + return; } - }, [isEditError, editError]); - - const checkIncludeImage = () => { - if (imageUrl !== '') return true; - - return voteInfo.options.map(option => option.imageUrl).some(url => url !== ''); - }; + }, [isEditVoteError, editVoteError]); const isPreviewTabIndex = isPreview ? undefined : 0; @@ -116,11 +126,6 @@ export default function Post({ postInfo, isPreview }: PostProps) { > {category.map(category => category.name).join(' | ')} - {isPreview && checkIncludeImage() && ( - - - - )} - {convertTimeToWord(createTime)} + {`${convertTimeToWord(createTime)} |`} {linkifyText(content)} - {!isPreview && imageUrl && ( - - )} + {!isPreview && imageUrl && } + {isPreview && ( + + + + {imageCount} + + + + {commentCount} + + + )} {isToastOpen && ( {toastMessage} @@ -178,4 +193,4 @@ export default function Post({ postInfo, isPreview }: PostProps) { )} ); -} +}); diff --git a/frontend/src/components/common/Post/mockData.ts b/frontend/src/components/common/Post/mockData.ts index 0395adeb1..3c6002196 100644 --- a/frontend/src/components/common/Post/mockData.ts +++ b/frontend/src/components/common/Post/mockData.ts @@ -27,6 +27,8 @@ export const MOCK_NOT_VOTE_POST: PostInfo = { ], createTime: '2023-07-12 12:40', deadline: '2023-07-20 18:40', + imageCount: 2, + commentCount: 8, voteInfo: { selectedOptionId: 0, allPeopleCount: 100, @@ -90,6 +92,8 @@ export const MOCK_VOTE_POST: PostInfo = { ], createTime: '2023-07-12 12:40', deadline: '2023-07-21 18:40', + imageCount: 0, + commentCount: 1, voteInfo: { selectedOptionId: 12312, allPeopleCount: 123, diff --git a/frontend/src/components/common/Post/style.ts b/frontend/src/components/common/Post/style.ts index 46a99b7d9..91cbe933c 100644 --- a/frontend/src/components/common/Post/style.ts +++ b/frontend/src/components/common/Post/style.ts @@ -26,27 +26,6 @@ export const Category = styled.span` } `; -export const ImageIconWrapper = styled.div` - display: flex; - justify-content: center; - align-items: center; - - width: 15px; - height: 15px; - border-radius: 50%; - - position: absolute; - right: 25px; - top: 0; - - background-color: var(--header); -`; - -export const ImageIcon = styled.img` - width: 13px; - height: 13px; -`; - export const ActivateState = styled.div<{ $isActive: boolean }>` width: 15px; height: 15px; @@ -119,14 +98,38 @@ export const DetailLink = styled(Link)<{ $isPreview: boolean }>` `; export const Image = styled.img` - width: 100%; + width: 80%; border-radius: 4px; margin-bottom: 10px; + border: 1px solid var(--gray); + align-self: center; aspect-ratio: 1/1; - object-fit: cover; + object-fit: contain; @media (min-width: ${theme.breakpoint.md}) { margin-bottom: 20px; } `; + +export const PreviewBottom = styled.div` + display: flex; + justify-content: flex-end; + gap: 15px; + + height: 30px; + margin-top: 10px; +`; + +export const IconUint = styled.div` + display: flex; + align-items: flex-end; + gap: 5px; + + font: var(--text-caption); +`; + +export const Icon = styled.img` + height: 24px; + width: 24px; +`; diff --git a/frontend/src/components/common/SquareButton/style.ts b/frontend/src/components/common/SquareButton/style.ts index a94729808..5b66fa3df 100644 --- a/frontend/src/components/common/SquareButton/style.ts +++ b/frontend/src/components/common/SquareButton/style.ts @@ -42,4 +42,8 @@ export const Button = styled.button` @media (min-width: ${theme.breakpoint.sm}) { font: var(--text-body); } + + @media (max-width: ${theme.breakpoint.sm}) { + font-size: 12px; + } `; diff --git a/frontend/src/components/common/TimePickerOptionList/TimePickerOption/constants.ts b/frontend/src/components/common/TimePickerOptionList/TimePickerOption/constants.ts index db46ae862..8e8ff7e0a 100644 --- a/frontend/src/components/common/TimePickerOptionList/TimePickerOption/constants.ts +++ b/frontend/src/components/common/TimePickerOptionList/TimePickerOption/constants.ts @@ -1,7 +1,9 @@ +import { MAX_DEADLINE } from '@constants/post'; + export const TIMEBOX_CHILD_HEIGHT = 33; export const TIME_UNIT: { [key: string]: number } = { - day: 3, + day: MAX_DEADLINE, hour: 24, minute: 60, }; diff --git a/frontend/src/components/common/TimePickerOptionList/index.tsx b/frontend/src/components/common/TimePickerOptionList/index.tsx index 7969dedf2..99ed2354d 100644 --- a/frontend/src/components/common/TimePickerOptionList/index.tsx +++ b/frontend/src/components/common/TimePickerOptionList/index.tsx @@ -1,21 +1,20 @@ import React, { Dispatch } from 'react'; +import { Time } from '@type/post'; + +import { MAX_DEADLINE } from '@constants/post'; + import * as S from './style'; import TimePickerOption from './TimePickerOption'; -interface Time { - day: number; - hour: number; - minute: number; -} - interface TimePickerOptionListProps { time: Time; setTime: Dispatch>; } export default function TimePickerOptionList({ time, setTime }: TimePickerOptionListProps) { - const { day, hour, minute } = time; + const changedTime = + time.day === MAX_DEADLINE ? { day: MAX_DEADLINE - 1, hour: 23, minute: 59 } : time; const updateTime = (option: string, updatedTime: number) => { setTime(prev => ({ @@ -27,19 +26,19 @@ export default function TimePickerOptionList({ time, setTime }: TimePickerOption return ( - {Object.entries(time).map(([key, value]) => ( + {Object.entries(changedTime).map(([key, value]) => ( + /> ))} -

{day}일

-

{hour}시

-

{minute}분

후 마감 +

{changedTime.day}일

+

{changedTime.hour}시

+

{changedTime.minute}분

후 마감
); diff --git a/frontend/src/components/common/Toast/style.ts b/frontend/src/components/common/Toast/style.ts index 347cb6ce6..045ac1cd4 100644 --- a/frontend/src/components/common/Toast/style.ts +++ b/frontend/src/components/common/Toast/style.ts @@ -19,6 +19,8 @@ export const fadeInOutAnimation = keyframes` export const Wrapper = styled.div<{ $position: 'top' | 'bottom' }>` position: fixed; + left: 50%; + transform: translateX(-50%); top: ${props => POSITION[props.$position]}; `; @@ -32,7 +34,7 @@ export const Content = styled.div<{ $size: Size | 'free' }>` height: ${props => SQUARE_SIZE[props.$size].height}; border-radius: 4px; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.6); color: var(--white); font: var(--text-caption); diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx index d3329025f..52a123c22 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx @@ -1,4 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta } from '@storybook/react'; + +import { useRef } from 'react'; import OptionUploadImageButton from '.'; @@ -7,8 +9,16 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; -export const Default: Story = { - render: () => , +export const Default = () => { + const ref = useRef([]); + + return ( + + ); }; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx index 7cf34b3d7..ff9437305 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, useRef } from 'react'; +import React, { MutableRefObject } from 'react'; import photoIcon from '@assets/photo_white.svg'; @@ -7,29 +7,36 @@ import * as S from './style'; interface OptionUploadImageButtonProps extends React.InputHTMLAttributes { optionId: number; isImageVisible: boolean; + contentInputRefList: MutableRefObject; + index: number; } export default function OptionUploadImageButton({ optionId, isImageVisible, + contentInputRefList, + index, ...rest }: OptionUploadImageButtonProps) { - const inputRef = useRef(null); const id = optionId.toString(); - const handleButtonClick = (e: MouseEvent) => { - e.preventDefault(); - inputRef.current && inputRef.current.click(); - }; - return ( - - + { + contentInputRefList.current[index] = ele; + }} + {...rest} + /> ); } diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts index 8509c65fd..23f3e08ad 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts @@ -6,6 +6,7 @@ export const Container = styled.div<{ $isVisible: boolean }>` width: 24px; height: 24px; border-radius: 50%; + visibility: ${props => props.$isVisible && 'hidden'}; `; @@ -14,6 +15,9 @@ export const Label = styled.label` `; export const FileInput = styled.input` + position: absolute; + left: 0; + visibility: hidden; `; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx index 7ce3b3f9d..5cfda953e 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx @@ -1,4 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta } from '@storybook/react'; + +import { useRef } from 'react'; import WritingVoteOption from '.'; @@ -7,10 +9,11 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; -export const IsDeletable: Story = { - render: () => ( +export const IsDeletable = () => { + const ref = useRef([]); + + return ( - ), + ); }; -export const IsNotDeletable: Story = { - render: () => ( +export const IsNotDeletable = () => { + const ref = useRef([]); + + return ( - ), + ); }; -export const ShowImage: Story = { - render: () => ( +export const ShowImage = () => { + const ref = useRef([]); + + return ( {}} @@ -56,6 +67,8 @@ export const ShowImage: Story = { 만 장마가 와서 취소했어요. 여행을 별로 좋" isDeletable={true} imageUrl="https://source.unsplash.com/random" + contentInputRefList={ref} + index={0} /> - ), + ); }; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx index d3bfa6eba..ddd51bfb7 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent } from 'react'; +import { ChangeEvent, MutableRefObject } from 'react'; import { POST_OPTION_POLICY } from '@constants/policyMessage'; @@ -16,6 +16,8 @@ interface WritingVoteOptionProps { handleRemoveImageClick: () => void; handleUploadImage: (event: ChangeEvent) => void; imageUrl: string; + contentInputRefList: MutableRefObject; + index: number; } const MAX_WRITING_LENGTH = 50; @@ -30,6 +32,8 @@ export default function WritingVoteOption({ handleRemoveImageClick, handleUploadImage, imageUrl, + contentInputRefList, + index, }: WritingVoteOptionProps) { return ( @@ -52,6 +56,8 @@ export default function WritingVoteOption({ isImageVisible={imageUrl.length > 0} optionId={optionId} onChange={handleUploadImage} + contentInputRefList={contentInputRefList} + index={index} /> {imageUrl && ( diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts index 27b301835..b5c1f3af5 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts @@ -5,6 +5,8 @@ import { theme } from '@styles/theme'; export const Container = styled.li` display: flex; gap: 10px; + + position: relative; `; export const OptionContainer = styled.div` @@ -56,7 +58,7 @@ export const Image = styled.img` border-radius: 4px; aspect-ratio: 1/1; - object-fit: cover; + object-fit: contain; `; export const ImageCancelWrapper = styled.div` diff --git a/frontend/src/components/optionList/WritingVoteOptionList/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx index a579ee04a..d6944bc35 100644 --- a/frontend/src/components/optionList/WritingVoteOptionList/index.tsx +++ b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent } from 'react'; +import { ChangeEvent, MutableRefObject } from 'react'; import { WritingVoteOptionType } from '@hooks/useWritingOption'; @@ -20,12 +20,20 @@ interface WritingVoteOptionListProps { deleteOption: (optionId: number) => void; removeImage: (optionId: number) => void; handleUploadImage: (event: ChangeEvent, optionId: number) => void; + contentInputRefList: MutableRefObject; }; } export default function WritingVoteOptionList({ writingOptionHook }: WritingVoteOptionListProps) { - const { optionList, addOption, writingOption, deleteOption, removeImage, handleUploadImage } = - writingOptionHook; + const { + optionList, + addOption, + writingOption, + deleteOption, + removeImage, + handleUploadImage, + contentInputRefList, + } = writingOptionHook; const isDeletable = optionList.length > MINIMUM_COUNT; return ( @@ -44,6 +52,8 @@ export default function WritingVoteOptionList({ writingOptionHook }: WritingVote handleUploadImage(event, optionItem.id) } imageUrl={optionItem.imageUrl} + contentInputRefList={contentInputRefList} + index={index} /> ))} {optionList.length < MAXIMUM_COUNT && ( diff --git a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/index.tsx b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/index.tsx index fa6f6223d..63e0b4dd9 100644 --- a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/index.tsx +++ b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/index.tsx @@ -1,5 +1,3 @@ -import { convertImageUrlToServerUrl } from '@utils/post/convertImageUrlToServerUrl'; - import ProgressBar from './ProgressBar'; import * as S from './style'; @@ -33,9 +31,7 @@ export default function WrittenVoteOption({ $isSelected={isSelected} onClick={handleVoteClick} > - {!isPreview && imageUrl && ( - - )} + {!isPreview && imageUrl && } {isPreview ? ( {text} ) : ( diff --git a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts index a600844f1..1694f14b9 100644 --- a/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts +++ b/frontend/src/components/optionList/WrittenVoteOptionList/WrittenVoteOption/style.ts @@ -25,12 +25,13 @@ export const Container = styled.button<{ $isSelected: boolean }>` export const Image = styled.img` border-radius: 4px; + border: 1px solid var(--gray); margin-bottom: 10px; - width: 100%; + width: 80%; aspect-ratio: 1/1; - object-fit: cover; + object-fit: contain; @media (min-width: ${theme.breakpoint.md}) { margin-bottom: 20px; diff --git a/frontend/src/components/post/PostList/index.tsx b/frontend/src/components/post/PostList/index.tsx index 616d90f1a..61e6ff891 100644 --- a/frontend/src/components/post/PostList/index.tsx +++ b/frontend/src/components/post/PostList/index.tsx @@ -1,5 +1,4 @@ import React, { useContext, useEffect, useRef } from 'react'; -import { Link } from 'react-router-dom'; import { AuthContext } from '@hooks/context/auth'; import { PostOptionContext } from '@hooks/context/postOption'; @@ -92,7 +91,7 @@ export default function PostList() { /> - + {isPostListEmpty && ( @@ -107,10 +106,17 @@ export default function PostList() { ); } + return ; })} - - + + ))} {isFetchingNextPage && } diff --git a/frontend/src/components/post/PostList/style.ts b/frontend/src/components/post/PostList/style.ts index 0680cd1df..80159b885 100644 --- a/frontend/src/components/post/PostList/style.ts +++ b/frontend/src/components/post/PostList/style.ts @@ -1,3 +1,5 @@ +import { Link } from 'react-router-dom'; + import { styled } from 'styled-components'; import { theme } from '@styles/theme'; @@ -28,6 +30,11 @@ export const PostListContainer = styled.ul` padding: 30px 20px; + > div > li { + padding-bottom: 30px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + > li { padding-bottom: 30px; border-bottom: 1px solid rgba(0, 0, 0, 0.1); @@ -46,3 +53,11 @@ export const SelectWrapper = styled.div` right: 20px; } `; + +export const HiddenButton = styled.button` + position: absolute; +`; + +export const HiddenLink = styled(Link)` + position: absolute; +`; diff --git a/frontend/src/constants/policyMessage.ts b/frontend/src/constants/policyMessage.ts index 1351df7f8..4a8f7f693 100644 --- a/frontend/src/constants/policyMessage.ts +++ b/frontend/src/constants/policyMessage.ts @@ -1,3 +1,5 @@ +import { MAX_DEADLINE } from './post'; + export const NICKNAME_POLICY = { LETTER_AMOUNT: '2자에서 15자 이내로 입력해주세요.', NO_DUPLICATION: '중복된 닉네임은 사용할 수 없습니다.', @@ -33,7 +35,7 @@ export const POST_OPTION_POLICY = { }; export const POST_DEADLINE_POLICY = { - DEFAULT: '3일 이내로 마감시간을 정해주세요.', + DEFAULT: `${MAX_DEADLINE}일 이내로 마감시간을 정해주세요.`, }; export const CONTENT_PLACEHOLDER = [ diff --git a/frontend/src/constants/post.ts b/frontend/src/constants/post.ts index cca99a1bc..ffa566be4 100644 --- a/frontend/src/constants/post.ts +++ b/frontend/src/constants/post.ts @@ -42,7 +42,7 @@ export const REQUEST_POST_KIND_URL = { export const SEARCH_KEYWORD = 'keyword'; -export const MAX_FILE_SIZE = 1500000; +export const MAX_FILE_SIZE = 10000000; export const POST_TITLE = { MAX_LENGTH: 100, @@ -66,4 +66,5 @@ export const DEFAULT_KEYWORD = ''; export const CATEGORY_COUNT_LIMIT = 3; -export const IMAGE_BASE_URL = `${process.env.VOTOGETHER_BASE_URL.replace(/api\./, '')}/`; +//단위는 0일 +export const MAX_DEADLINE = 14; diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts index 5d4821fa8..0d4991e29 100644 --- a/frontend/src/constants/queryKey.ts +++ b/frontend/src/constants/queryKey.ts @@ -6,4 +6,5 @@ export const QUERY_KEY = { USER_INFO: 'user_info', PASSION_RANKING: 'passion_ranking', POPULAR_RANKING: 'popular_ranking', + VOTE_STATISTICS: 'vote_statistics', }; diff --git a/frontend/src/constants/user.ts b/frontend/src/constants/user.ts index d509d9dfa..1928ff5b5 100644 --- a/frontend/src/constants/user.ts +++ b/frontend/src/constants/user.ts @@ -1,9 +1,14 @@ -export const NICKNAME = { +export interface TextLimit { + MAX_LENGTH: number; + MIN_LENGTH: number; +} + +export const NICKNAME: TextLimit = { MAX_LENGTH: 15, MIN_LENGTH: 2, } as const; -export const BIRTH_YEAR = { +export const BIRTH_YEAR: TextLimit = { MAX_LENGTH: new Date().getFullYear(), MIN_LENGTH: 1900, } as const; diff --git a/frontend/src/hooks/query/category/useCategoryFavoriteToggle.ts b/frontend/src/hooks/query/category/useCategoryFavoriteToggle.ts index 13fa830ca..5c238285f 100644 --- a/frontend/src/hooks/query/category/useCategoryFavoriteToggle.ts +++ b/frontend/src/hooks/query/category/useCategoryFavoriteToggle.ts @@ -8,16 +8,36 @@ import { QUERY_KEY } from '@constants/queryKey'; export const useCategoryFavoriteToggle = () => { const queryClient = useQueryClient(); + const LOGGED_IN = true; + const queryKey = [QUERY_KEY.CATEGORIES, LOGGED_IN]; + const { mutate, isLoading, isError, error } = useMutation( ({ id, isFavorite }: Omit) => isFavorite ? removeFavoriteCategory(id) : addFavoriteCategory(id), { - onSuccess: () => { - queryClient.invalidateQueries([QUERY_KEY.CATEGORIES]); + onMutate: async ({ id }: Omit) => { + const oldCategoryList: Category[] | undefined = queryClient.getQueryData(queryKey); + + if (oldCategoryList) { + await queryClient.cancelQueries(queryKey); + const updatedCategoryList = oldCategoryList.map(item => + item.id === id ? { ...item, isFavorite: !item.isFavorite } : item + ); + queryClient.setQueryData(queryKey, updatedCategoryList); + + return () => queryClient.setQueryData(queryKey, oldCategoryList); + } }, - onError: error => { + onError: (error, _, rollback) => { + if (rollback) { + rollback(); + return; + } window.console.log('Category favorite toggle error', error); }, + onSettled: () => { + queryClient.invalidateQueries(queryKey); + }, } ); diff --git a/frontend/src/hooks/query/post/useDeletePost.ts b/frontend/src/hooks/query/post/useDeletePost.ts index 8be37d2bb..daf71e52a 100644 --- a/frontend/src/hooks/query/post/useDeletePost.ts +++ b/frontend/src/hooks/query/post/useDeletePost.ts @@ -7,7 +7,7 @@ import { QUERY_KEY } from '@constants/queryKey'; export const useDeletePost = (postId: number, isLogged: boolean) => { const queryClient = useQueryClient(); - const { mutate, isSuccess, isError, error } = useMutation({ + const { mutate, isSuccess, isError, error, isLoading } = useMutation({ mutationFn: () => deletePost(postId), onSuccess: () => { queryClient.invalidateQueries([QUERY_KEY.USER_INFO, isLogged]); @@ -17,5 +17,5 @@ export const useDeletePost = (postId: number, isLogged: boolean) => { }, }); - return { mutate, isSuccess, isError, error }; + return { mutate, isSuccess, isError, error, isLoading }; }; diff --git a/frontend/src/hooks/query/post/usePostDetail.ts b/frontend/src/hooks/query/post/usePostDetail.ts index 643d1256d..2824693ea 100644 --- a/frontend/src/hooks/query/post/usePostDetail.ts +++ b/frontend/src/hooks/query/post/usePostDetail.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { PostInfo } from '@type/post'; @@ -6,14 +6,30 @@ import { getPost, getPostForGuest } from '@api/post'; import { QUERY_KEY } from '@constants/queryKey'; +import { checkClosedPost } from '@utils/time'; + export const usePostDetail = (isLoggedIn: boolean, postId: number) => { const fetchApi = isLoggedIn ? getPost : getPostForGuest; + const queryClient = useQueryClient(); + const POST_DETAIL_QUERY_KEY = [QUERY_KEY.POST_DETAIL, postId, isLoggedIn]; + const { data, isError, isLoading, error } = useQuery( - [QUERY_KEY.POST_DETAIL, postId, isLoggedIn], + POST_DETAIL_QUERY_KEY, () => fetchApi(postId), { suspense: true, + + onSuccess: data => { + if (checkClosedPost(data.deadline)) { + queryClient.setQueryDefaults(POST_DETAIL_QUERY_KEY, { + cacheTime: 60 * 60 * 1000, + staleTime: 60 * 60 * 1000, + }); + } + + return data; + }, } ); diff --git a/frontend/src/hooks/query/useVoteStatistics.tsx b/frontend/src/hooks/query/useVoteStatistics.tsx new file mode 100644 index 000000000..61eba626b --- /dev/null +++ b/frontend/src/hooks/query/useVoteStatistics.tsx @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getOptionStatistics, getPostStatistics } from '@api/voteResult'; + +import { VoteResult } from '@components/VoteStatistics/type'; + +import { QUERY_KEY } from '@constants/queryKey'; + +export const useVoteStatistics = (postId: number, optionId?: number) => { + const { data } = useQuery( + optionId ? [QUERY_KEY.VOTE_STATISTICS, postId, optionId] : [QUERY_KEY.VOTE_STATISTICS, postId], + () => (optionId ? getOptionStatistics({ postId, optionId }) : getPostStatistics(postId)), + { + cacheTime: 60 * 60 * 1000, + staleTime: 60 * 60 * 1000, + suspense: true, + } + ); + + return { data }; +}; diff --git a/frontend/src/hooks/useContentImage.ts b/frontend/src/hooks/useContentImage.ts index 8a859eed2..7c0a46675 100644 --- a/frontend/src/hooks/useContentImage.ts +++ b/frontend/src/hooks/useContentImage.ts @@ -1,48 +1,43 @@ -import { ChangeEvent, useRef, useState } from 'react'; +import { ChangeEvent, ClipboardEvent, useRef, useState } from 'react'; -import { MAX_FILE_SIZE } from '@components/PostForm/constants'; - -import { convertImageToWebP } from '@utils/resizeImage'; +import { uploadImage } from '@utils/post/uploadImage'; export const useContentImage = (imageUrl: string = '') => { const [contentImage, setContentImage] = useState(imageUrl); const contentInputRef = useRef(null); + const handlePasteImage = (event: ClipboardEvent) => { + const file = event.clipboardData.files[0]; + + if (file.type.slice(0, 5) === 'image') { + event.preventDefault(); + + uploadImage({ + imageFile: file, + inputElement: contentInputRef.current, + setPreviewImageUrl: setContentImage, + }); + } + }; + const removeImage = () => { setContentImage(''); if (contentInputRef.current) contentInputRef.current.value = ''; }; - const handleUploadImage = async (event: ChangeEvent) => { + const handleUploadImage = (event: ChangeEvent) => { const { files } = event.target; if (!files) return; const file = files[0]; - const webpFileList = await convertImageToWebP(file); - - event.target.files = webpFileList; - - const reader = new FileReader(); - - const webpFile = webpFileList[0]; - - reader.readAsDataURL(webpFile); - - event.target.setCustomValidity(''); - - if (file.size > MAX_FILE_SIZE) { - event.target.setCustomValidity('사진의 용량은 1.5MB 이하만 가능합니다.'); - event.target.reportValidity(); - - return; - } - - reader.onloadend = () => { - setContentImage(reader.result?.toString() ?? ''); - }; + uploadImage({ + imageFile: file, + inputElement: contentInputRef.current, + setPreviewImageUrl: setContentImage, + }); }; - return { contentImage, contentInputRef, removeImage, handleUploadImage }; + return { contentImage, contentInputRef, removeImage, handleUploadImage, handlePasteImage }; }; diff --git a/frontend/src/hooks/useFetch.ts b/frontend/src/hooks/useFetch.ts deleted file mode 100644 index fd1d4b872..000000000 --- a/frontend/src/hooks/useFetch.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -export const useFetch = (fetchFn: () => Promise) => { - const [data, setData] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const refetch = useCallback(() => { - setIsLoading(true); - setData(null); - setErrorMessage(null); - - fetchFn() - .then(res => { - setData(res); - }) - .catch(error => { - setErrorMessage(error.message); - }) - .finally(() => { - setIsLoading(false); - }); - }, [fetchFn]); - - useEffect(() => { - refetch(); - }, []); - - return { data, errorMessage, isLoading, refetch }; -}; diff --git a/frontend/src/hooks/useWritingOption.tsx b/frontend/src/hooks/useWritingOption.tsx index 4b465b0c6..6a91a986a 100644 --- a/frontend/src/hooks/useWritingOption.tsx +++ b/frontend/src/hooks/useWritingOption.tsx @@ -1,8 +1,6 @@ -import React, { ChangeEvent, useState } from 'react'; +import React, { ChangeEvent, useRef, useState } from 'react'; -import { MAX_FILE_SIZE } from '@components/PostForm/constants'; - -import { convertImageToWebP } from '@utils/resizeImage'; +import { uploadImage } from '@utils/post/uploadImage'; const MAX_WRITING_LENGTH = 50; @@ -16,19 +14,24 @@ const MIN_COUNT = 2; const MAX_COUNT = 5; const INIT_OPTION_LIST = [ - { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, - { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '', isServerId: false }, + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '', isServerId: false }, ]; -export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = INIT_OPTION_LIST) => { - const [optionList, setOptionList] = useState(initialOptionList); +export const useWritingOption = (initialOptionList?: WritingVoteOptionType[]) => { + const [optionList, setOptionList] = useState( + initialOptionList + ? initialOptionList.map(option => ({ ...option, isServerId: true })) + : INIT_OPTION_LIST + ); + const contentInputRefList = useRef([]); const addOption = () => { if (optionList.length >= MAX_COUNT) return; const updatedOptionList = [ ...optionList, - { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '', isServerId: false }, ]; setOptionList(updatedOptionList); @@ -77,6 +80,22 @@ export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = IN return optionItem; }); + setOptionList(updatedOptionList); + contentInputRefList.current && + contentInputRefList.current.forEach(inputElement => { + if (inputElement?.id === optionId.toString()) inputElement.value = ''; + }); + }; + + const setPreviewImageUrl = (optionId: number) => (imageUrl: string) => { + const updatedOptionList = optionList.map(optionItem => { + if (optionItem.id === optionId) { + return { ...optionItem, imageUrl }; + } + + return optionItem; + }); + setOptionList(updatedOptionList); }; @@ -90,37 +109,20 @@ export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = IN const file = files[0]; - const webpFileList = await convertImageToWebP(file); - - event.target.files = webpFileList; - - const reader = new FileReader(); - - const webpFile = webpFileList[0]; - - reader.readAsDataURL(webpFile); - - event.target.setCustomValidity(''); - - if (file.size > MAX_FILE_SIZE) { - event.target.setCustomValidity('사진의 용량은 1.5MB 이하만 가능합니다.'); - event.target.reportValidity(); - - return; - } - - reader.onloadend = () => { - const updatedOptionList = optionList.map(optionItem => { - if (optionItem.id === optionId) { - return { ...optionItem, imageUrl: reader.result?.toString() ?? '' }; - } - - return optionItem; - }); - - setOptionList(updatedOptionList); - }; + uploadImage({ + imageFile: file, + inputElement: event.target, + setPreviewImageUrl: setPreviewImageUrl(optionId), + }); }; - return { optionList, addOption, writingOption, deleteOption, removeImage, handleUploadImage }; + return { + optionList, + addOption, + writingOption, + deleteOption, + removeImage, + handleUploadImage, + contentInputRefList, + }; }; diff --git a/frontend/src/mocks/mockData/post.ts b/frontend/src/mocks/mockData/post.ts index 2a12707c4..539de1cfe 100644 --- a/frontend/src/mocks/mockData/post.ts +++ b/frontend/src/mocks/mockData/post.ts @@ -29,6 +29,8 @@ const getMockPost = (): PostInfoResponse => ({ ], createdAt: '2023-07-12 12:40', deadline: '2023-07-13 18:40', + imageCount: 2, + commentCount: 5, voteInfo: { selectedOptionId: 9, totalVoteCount: 123, @@ -91,6 +93,8 @@ const getMockGuestPost = (): PostInfoResponse => ({ ], createdAt: '2023-07-12 12:40', deadline: '2023-07-13 18:40', + imageCount: 0, + commentCount: 1, voteInfo: { selectedOptionId: 0, totalVoteCount: 0, diff --git a/frontend/src/pages/ErrorBoundaryWithNarrowHeader.tsx b/frontend/src/pages/ErrorBoundaryWithNarrowHeader.tsx deleted file mode 100644 index 5c7fd3b9c..000000000 --- a/frontend/src/pages/ErrorBoundaryWithNarrowHeader.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; - -import Error from './Error'; -import ErrorBoundary from './ErrorBoundary'; - -class ErrorBoundaryWithNarrowHeader extends ErrorBoundary { - render() { - if (this.state.hasError) { - return ( - <> - - - - ); - } - - return this.props.children; - } -} - -export default ErrorBoundaryWithNarrowHeader; diff --git a/frontend/src/pages/MyInfo/index.tsx b/frontend/src/pages/MyInfo/index.tsx index d21613ae2..c09c59fac 100644 --- a/frontend/src/pages/MyInfo/index.tsx +++ b/frontend/src/pages/MyInfo/index.tsx @@ -1,10 +1,9 @@ -import { useContext, ChangeEvent, useEffect } from 'react'; +import { useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '@hooks/context/auth'; import { useModifyUser } from '@hooks/query/user/useModifyUser'; import { useWithdrawalMembership } from '@hooks/query/user/useWithdrawalMembership'; -import { useText } from '@hooks/useText'; import { useToast } from '@hooks/useToast'; import { useToggle } from '@hooks/useToggle'; @@ -20,6 +19,8 @@ import Toast from '@components/common/Toast'; import { NICKNAME_POLICY } from '@constants/policyMessage'; import { NICKNAME } from '@constants/user'; +import InputNSubmitButton from '../../components/common/InputNSubmitButton'; + import DeleteMemberModal from './DeleteMemberModal'; import * as S from './style'; @@ -43,11 +44,8 @@ export default function MyInfo() { const { isOpen, openComponent, closeComponent } = useToggle(); const { loggedInfo, clearLoggedInfo } = useContext(AuthContext); - const { text: newNickname, handleTextChange: handleNicknameChange } = useText( - loggedInfo.userInfo?.nickname ?? '' - ); - const handleModifyNickname = () => { + const handleModifyNickname = (newNickname: string) => { modifyNickname(newNickname); }; @@ -111,18 +109,12 @@ export default function MyInfo() {
  • - {NICKNAME_POLICY.NO_DUPLICATION}
  • - {NICKNAME_POLICY.LIMIT_KOREAN}
  • - - ) => handleNicknameChange(e, NICKNAME)} - placeholder="새로운 닉네임을 입력해주세요" - /> - - - 변경 - - - + diff --git a/frontend/src/pages/MyInfo/style.ts b/frontend/src/pages/MyInfo/style.ts index 7efd05cbf..6ed63483a 100644 --- a/frontend/src/pages/MyInfo/style.ts +++ b/frontend/src/pages/MyInfo/style.ts @@ -49,23 +49,6 @@ export const DescribeUl = styled.ul` margin: 0 0 20px 5px; `; -export const InputWrapper = styled.div` - display: flex; - align-items: center; - gap: 10px; -`; - -export const Input = styled.input` - width: 80%; - border: 1px solid #f2f2f2; - padding: 20px; -`; - -export const ButtonWrapper = styled.div` - width: 90px; - height: 50px; -`; - export const ModalBody = styled.div` display: flex; flex-direction: column; @@ -96,3 +79,8 @@ export const ButtonListWrapper = styled.div` width: 90%; height: 50px; `; + +export const ButtonWrapper = styled.div` + width: 90px; + height: 50px; +`; diff --git a/frontend/src/pages/Ranking/PageContent/index.tsx b/frontend/src/pages/Ranking/PageContent/index.tsx new file mode 100644 index 000000000..7f2513e92 --- /dev/null +++ b/frontend/src/pages/Ranking/PageContent/index.tsx @@ -0,0 +1,45 @@ +import { Suspense } from 'react'; + +import { useToggleSwitch } from '@hooks/useToggleSwitch'; + +import ErrorBoundary from '@pages/ErrorBoundary'; + +import LoadingSpinner from '@components/common/LoadingSpinner'; +import ToggleSwitch from '@components/common/ToggleSwitch'; + +import PassionUserRanking from '../PassionUser'; +import PopularPost from '../PopularPost'; +import * as RS from '../RankingTableStyle'; + +export default function PageContent() { + const { selectedButton, firstButton, secondButton } = useToggleSwitch('열정 유저', '인기글 유저'); + + return ( + <> + + {selectedButton === '열정 유저' && ( + + + }> + + + + + )} + {selectedButton === '인기글 유저' && ( + + + }> + + + + + )} + + ); +} diff --git a/frontend/src/pages/Ranking/PassionUser/index.tsx b/frontend/src/pages/Ranking/PassionUser/index.tsx index 5545696db..7c807da85 100644 --- a/frontend/src/pages/Ranking/PassionUser/index.tsx +++ b/frontend/src/pages/Ranking/PassionUser/index.tsx @@ -1,14 +1,8 @@ -import { Suspense } from 'react'; - import { usePassionUserRanking } from '@hooks/query/ranking/usePassionUserRanking'; -import ErrorBoundary from '@pages/ErrorBoundary'; - -import LoadingSpinner from '@components/common/LoadingSpinner'; - -import firstRankIcon from '@assets/first-rank.svg'; -import secondRankIcon from '@assets/second-rank.svg'; -import thirdRankIcon from '@assets/third-rank.svg'; +import firstRankIcon from '@assets/first-rank.webp'; +import secondRankIcon from '@assets/second-rank.webp'; +import thirdRankIcon from '@assets/third-rank.webp'; import * as S from './style'; import UserRanking from './UserRanking'; @@ -25,47 +19,43 @@ export default function PassionUserRanking() { const { data: rankerList } = usePassionUserRanking(); return ( - - - {columnNameList.map(text => ( - {text} - ))} - - {rankerList && - new Array(10).fill(0).map((_, index) => { - const ranker = rankerList[index] ?? { - ranking: '', - nickname: '', - postCount: '', - voteCount: '', - score: '', - }; - - const rankIcon = rankIconUrl[ranker.ranking] && ( - {ranker.ranking.toString()} - ); - - return ( - - {rankIcon ?? ranker.ranking} - {ranker.nickname} - {ranker.postCount} - {ranker.voteCount} - {ranker.score} - - ); - })} - - - - - } - > + <> + + + + {columnNameList.map(text => ( + {text} + ))} + + + + {rankerList && + new Array(10).fill(0).map((_, index) => { + const ranker = rankerList[index] ?? { + ranking: '', + nickname: '', + postCount: '', + voteCount: '', + score: '', + }; + + const rankIcon = rankIconUrl[ranker.ranking] && ( + {ranker.ranking.toString()} + ); + + return ( + + {rankIcon ?? ranker.ranking} + {ranker.nickname} + {ranker.postCount} + {ranker.voteCount} + {ranker.score} + + ); + })} - - - + + + ); } diff --git a/frontend/src/pages/Ranking/PassionUser/style.ts b/frontend/src/pages/Ranking/PassionUser/style.ts index 2058d7b3b..b3487356a 100644 --- a/frontend/src/pages/Ranking/PassionUser/style.ts +++ b/frontend/src/pages/Ranking/PassionUser/style.ts @@ -17,6 +17,18 @@ export const Table = styled.table` } `; +export const Tbody = styled.tbody` + & > :nth-child(11) { + margin-top: 20px; + padding: 3px 0; + border-radius: 4px; + + background-color: var(--white); + + font-weight: 500; + } +`; + export const Tr = styled.tr` display: grid; grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1fr; diff --git a/frontend/src/pages/Ranking/PopularPost/index.tsx b/frontend/src/pages/Ranking/PopularPost/index.tsx index 76c95f383..423f261b2 100644 --- a/frontend/src/pages/Ranking/PopularPost/index.tsx +++ b/frontend/src/pages/Ranking/PopularPost/index.tsx @@ -4,9 +4,9 @@ import { usePopularPostRanking } from '@hooks/query/ranking/usePopularPostRankin import { PATH } from '@constants/path'; -import firstRankIcon from '@assets/first-rank.svg'; -import secondRankIcon from '@assets/second-rank.svg'; -import thirdRankIcon from '@assets/third-rank.svg'; +import firstRankIcon from '@assets/first-rank.webp'; +import secondRankIcon from '@assets/second-rank.webp'; +import thirdRankIcon from '@assets/third-rank.webp'; import * as S from './style'; @@ -23,28 +23,32 @@ export default function PopularPost() { return ( - - {columnNameList.map(text => ( - {text} - ))} - - {rankingPostList && - rankingPostList.map(rankingPost => { - const rankIcon = rankIconUrl[rankingPost.ranking] && ( - {rankingPost.ranking.toString()} - ); - - return ( - - {rankIcon ?? rankingPost.ranking} - {rankingPost.post.writer} - - {rankingPost.post.title} - - {rankingPost.post.voteCount} - - ); - })} + + + {columnNameList.map(text => ( + {text} + ))} + + + + {rankingPostList && + rankingPostList.map((rankingPost, index) => { + const rankIcon = rankIconUrl[rankingPost.ranking] && ( + {rankingPost.ranking.toString()} + ); + + return ( + + {rankIcon ?? rankingPost.ranking} + {rankingPost.post.writer} + + {rankingPost.post.title} + + {rankingPost.post.voteCount} + + ); + })} + ); } diff --git a/frontend/src/pages/Ranking/index.tsx b/frontend/src/pages/Ranking/index.tsx index 3afe86d49..d32a020c4 100644 --- a/frontend/src/pages/Ranking/index.tsx +++ b/frontend/src/pages/Ranking/index.tsx @@ -1,27 +1,17 @@ -import { Suspense } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useToggleSwitch } from '@hooks/useToggleSwitch'; - -import ErrorBoundary from '@pages/ErrorBoundary'; - import IconButton from '@components/common/IconButton'; import Layout from '@components/common/Layout'; -import LoadingSpinner from '@components/common/LoadingSpinner'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; -import ToggleSwitch from '@components/common/ToggleSwitch'; -import PassionUserRanking from './PassionUser'; -import PopularPost from './PopularPost'; -import * as RS from './RankingTableStyle'; +import PageContent from './PageContent'; import * as S from './style'; export default function Ranking() { const navigate = useNavigate(); - const { selectedButton, firstButton, secondButton } = useToggleSwitch('열정 유저', '인기글 유저'); return ( - + 🏆 랭킹 🏆 - - {selectedButton === '열정 유저' && ( - - - }> - - - - - )} - {selectedButton === '인기글 유저' && ( - - - }> - - - - - )} +
    diff --git a/frontend/src/pages/VoteStatisticsPage/OptionStatistics/index.tsx b/frontend/src/pages/VoteStatisticsPage/OptionStatistics/index.tsx index 109d720eb..e47c030fe 100644 --- a/frontend/src/pages/VoteStatisticsPage/OptionStatistics/index.tsx +++ b/frontend/src/pages/VoteStatisticsPage/OptionStatistics/index.tsx @@ -1,17 +1,14 @@ -import { useState } from 'react'; +import { Suspense, useState } from 'react'; import { WrittenVoteOptionType } from '@type/post'; import { Size } from '@type/style'; -import { useFetch } from '@hooks/useFetch'; -import { useToast } from '@hooks/useToast'; - -import { getOptionStatistics } from '@api/voteResult'; +import ErrorBoundary from '@pages/ErrorBoundary'; import LoadingSpinner from '@components/common/LoadingSpinner'; -import Toast from '@components/common/Toast'; import WrittenVoteOption from '@components/optionList/WrittenVoteOptionList/WrittenVoteOption'; -import VoteStatistics from '@components/VoteStatistics'; + +import StatisticsWrapper from '../StatisticsWrapper'; import * as S from './style'; @@ -29,17 +26,8 @@ export default function OptionStatistics({ size, }: OptionStatisticsProps) { const [isStatisticsOpen, setIsStatisticsOpen] = useState(false); - const { isToastOpen, openToast, toastMessage } = useToast(); - - const { - data: voteResult, - errorMessage, - isLoading, - } = useFetch(() => getOptionStatistics({ postId, optionId: voteOption.id })); const toggleOptionStatistics = () => { - if (!voteResult) return openToast('투표 통계 불러오기를 실패했습니다.'); - setIsStatisticsOpen(!isStatisticsOpen); }; @@ -60,26 +48,25 @@ export default function OptionStatistics({ 투표 선택지를 클릭하여 투표 통계를 열어 확인할 수 있습니다. )} - {isStatisticsOpen && voteResult && ( + {isStatisticsOpen && ( <> 투표 선택지를 클릭하여 투표 통계를 닫을 수 있습니다. - + + + + + } + > + + + )} - {isStatisticsOpen && isLoading && ( - - - - )} - {isStatisticsOpen && errorMessage} - {isToastOpen && ( - - {toastMessage} - - )} ); } diff --git a/frontend/src/pages/VoteStatisticsPage/OptionWrapper/index.tsx b/frontend/src/pages/VoteStatisticsPage/OptionWrapper/index.tsx new file mode 100644 index 000000000..2939a4452 --- /dev/null +++ b/frontend/src/pages/VoteStatisticsPage/OptionWrapper/index.tsx @@ -0,0 +1,35 @@ +import { useContext } from 'react'; +import { Navigate } from 'react-router-dom'; + +import { AuthContext } from '@hooks/context/auth'; +import { usePostDetail } from '@hooks/query/post/usePostDetail'; + +import { PATH } from '@constants/path'; + +import { checkWriter } from '@utils/post/checkWriter'; + +import OptionStatistics from '../OptionStatistics'; + +export default function OptionWrapper({ postId }: { postId: number }) { + const { isLoggedIn } = useContext(AuthContext).loggedInfo; + const { data: postDetail } = usePostDetail(isLoggedIn, postId); + + if (!isLoggedIn && postDetail && !checkWriter(postDetail.writer.id)) + return ; + + return ( + postDetail && + postDetail.voteInfo.options.map(option => { + const { postId, voteInfo } = postDetail; + return ( + + ); + }) + ); +} diff --git a/frontend/src/pages/VoteStatisticsPage/StatisticsWrapper/index.tsx b/frontend/src/pages/VoteStatisticsPage/StatisticsWrapper/index.tsx new file mode 100644 index 000000000..b249b28fa --- /dev/null +++ b/frontend/src/pages/VoteStatisticsPage/StatisticsWrapper/index.tsx @@ -0,0 +1,19 @@ +import { Size } from '@type/style'; + +import { useVoteStatistics } from '@hooks/query/useVoteStatistics'; + +import VoteStatistics from '@components/VoteStatistics'; + +export default function StatisticsWrapper({ + size, + postId, + optionId, +}: { + size: Size; + postId: number; + optionId?: number; +}) { + const { data: voteResult } = useVoteStatistics(postId, optionId); + + return voteResult && ; +} diff --git a/frontend/src/pages/VoteStatisticsPage/index.tsx b/frontend/src/pages/VoteStatisticsPage/index.tsx index a541b684a..a4328adf1 100644 --- a/frontend/src/pages/VoteStatisticsPage/index.tsx +++ b/frontend/src/pages/VoteStatisticsPage/index.tsx @@ -1,44 +1,22 @@ -import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { Suspense } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; -import { useFetch } from '@hooks/useFetch'; +import ErrorBoundary from '@pages/ErrorBoundary'; -import { getPost } from '@api/post'; -import { getPostStatistics } from '@api/voteResult'; - -import ErrorMessage from '@components/common/ErrorMessage'; import IconButton from '@components/common/IconButton'; import Layout from '@components/common/Layout'; +import LoadingSpinner from '@components/common/LoadingSpinner'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; -import Skeleton from '@components/common/Skeleton'; -import VoteStatistics from '@components/VoteStatistics'; - -import { PATH } from '@constants/path'; -import { checkWriter } from '@utils/post/checkWriter'; - -import OptionStatistics from './OptionStatistics'; +import OptionWrapper from './OptionWrapper'; +import StatisticsWrapper from './StatisticsWrapper'; import * as S from './style'; export default function VoteStatisticsPage() { const params = useParams() as { postId: string }; const postId = Number(params.postId); - const navigate = useNavigate(); - const { - data: postDetail, - errorMessage: postError, - isLoading: isPostLoading, - } = useFetch(() => getPost(postId)); - - const { - data: voteResultResponse, - errorMessage: voteResultError, - isLoading: isVoteResultLoading, - } = useFetch(() => getPostStatistics(postId)); - - if (postDetail && !checkWriter(postDetail.writer.id)) return ; - return ( @@ -48,37 +26,20 @@ export default function VoteStatisticsPage() { 투표 통계 - {postError && } - {isPostLoading && ( - - - - )} - {postDetail && ( - - {voteResultError && } - {isVoteResultLoading && ( - - - - )} - {voteResultResponse && ( - - )} - {postDetail.voteInfo.options.map(option => { - const { postId, voteInfo } = postDetail; - return ( - - ); - })} - - )} + + + + + + } + > + + + + + ); diff --git a/frontend/src/pages/VoteStatisticsPage/style.ts b/frontend/src/pages/VoteStatisticsPage/style.ts index 329cf2fe7..574732f0c 100644 --- a/frontend/src/pages/VoteStatisticsPage/style.ts +++ b/frontend/src/pages/VoteStatisticsPage/style.ts @@ -35,7 +35,7 @@ export const PageHeader = styled.div` font: var(--text-title); `; -export const OptionContainer = styled.div` +export const ContentContainer = styled.div` display: flex; flex-direction: column; align-items: center; diff --git a/frontend/src/pages/auth/Login/MobileLogin/index.tsx b/frontend/src/pages/auth/Login/MobileLogin/index.tsx index 781c79532..adbb2800e 100644 --- a/frontend/src/pages/auth/Login/MobileLogin/index.tsx +++ b/frontend/src/pages/auth/Login/MobileLogin/index.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; -import kakao from '@assets/kakao_login_medium_wide.svg'; +import kakao from '@assets/kakao_login_medium_wide.webp'; import logo from '@assets/stroke-logo.svg'; import * as S from './style'; diff --git a/frontend/src/pages/auth/Login/ServiceIntroductionSection/ServiceIntroductionSection.stories.tsx b/frontend/src/pages/auth/Login/ServiceIntroductionSection/ServiceIntroductionSection.stories.tsx new file mode 100644 index 000000000..5eb8eccb0 --- /dev/null +++ b/frontend/src/pages/auth/Login/ServiceIntroductionSection/ServiceIntroductionSection.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ServiceIntroductionSection from '.'; + +const meta: Meta = { + component: ServiceIntroductionSection, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/pages/auth/Login/ServiceIntroductionSection/StartUsingOurService.stories.tsx b/frontend/src/pages/auth/Login/ServiceIntroductionSection/StartUsingOurService.stories.tsx deleted file mode 100644 index 286405d5f..000000000 --- a/frontend/src/pages/auth/Login/ServiceIntroductionSection/StartUsingOurService.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import StartUsingOurService from '.'; - -const meta: Meta = { - component: StartUsingOurService, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => , -}; diff --git a/frontend/src/pages/auth/Login/ServiceIntroductionSection/index.tsx b/frontend/src/pages/auth/Login/ServiceIntroductionSection/index.tsx index 233a3e45d..65cd37053 100644 --- a/frontend/src/pages/auth/Login/ServiceIntroductionSection/index.tsx +++ b/frontend/src/pages/auth/Login/ServiceIntroductionSection/index.tsx @@ -1,5 +1,5 @@ -import home from '@assets/votogether_home.png'; -import write from '@assets/votogether_write.png'; +import home from '@assets/votogether_home.webp'; +import write from '@assets/votogether_write.webp'; import * as S from './style'; diff --git a/frontend/src/pages/post/CreatePostPage/index.tsx b/frontend/src/pages/post/CreatePostPage/index.tsx index c8698091c..ea9a02479 100644 --- a/frontend/src/pages/post/CreatePostPage/index.tsx +++ b/frontend/src/pages/post/CreatePostPage/index.tsx @@ -14,7 +14,7 @@ import { SORTING, STATUS } from '@constants/post'; export default function CreatePostPage() { const navigate = useNavigate(); - const { mutate, isSuccess, isError, error } = useCreatePost(); + const { mutate, isSuccess, isError, error, isLoading } = useCreatePost(); const { isToastOpen, openToast, toastMessage } = useToast(); const { setPostOption } = useContext(PostOptionContext); @@ -35,7 +35,7 @@ export default function CreatePostPage() { return ( - + {isToastOpen && ( {toastMessage} diff --git a/frontend/src/pages/post/EditPostPage/EditPost/index.tsx b/frontend/src/pages/post/EditPostPage/EditPost/index.tsx index c1461d8d7..429ae695e 100644 --- a/frontend/src/pages/post/EditPostPage/EditPost/index.tsx +++ b/frontend/src/pages/post/EditPostPage/EditPost/index.tsx @@ -18,7 +18,7 @@ export default function EditPost() { const { postId } = useParams(); const { data } = usePostDetail(true, Number(postId)); - const { mutate, isSuccess, isError, error } = useEditPost(Number(postId)); + const { mutate, isSuccess, isError, error, isLoading } = useEditPost(Number(postId)); const { isToastOpen, openToast, toastMessage } = useToast(); const { setPostOption } = useContext(PostOptionContext); @@ -39,7 +39,7 @@ export default function EditPost() { return ( <> - + {isToastOpen && ( {toastMessage} diff --git a/frontend/src/pages/post/EditPostPage/index.tsx b/frontend/src/pages/post/EditPostPage/index.tsx index 43ec412ac..1249d4030 100644 --- a/frontend/src/pages/post/EditPostPage/index.tsx +++ b/frontend/src/pages/post/EditPostPage/index.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import ErrorBoundaryWithNarrowHeader from '@pages/ErrorBoundaryWithNarrowHeader'; +import ErrorBoundary from '@pages/ErrorBoundary'; import Layout from '@components/common/Layout'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; @@ -12,7 +12,7 @@ import * as S from './style'; export default function EditPostPage() { return ( - + @@ -25,7 +25,7 @@ export default function EditPostPage() { > - + ); } diff --git a/frontend/src/pages/post/PostDetail/BottomButtonPart/BottomButtonPart.stories.tsx b/frontend/src/pages/post/PostDetail/BottomButtonPart/BottomButtonPart.stories.tsx index 72551fdb3..da0cf2795 100644 --- a/frontend/src/pages/post/PostDetail/BottomButtonPart/BottomButtonPart.stories.tsx +++ b/frontend/src/pages/post/PostDetail/BottomButtonPart/BottomButtonPart.stories.tsx @@ -1,3 +1,4 @@ +import type { LoadingType } from '../types'; import type { Meta, StoryObj } from '@storybook/react'; import BottomButtonPart from '.'; @@ -24,18 +25,52 @@ const handleEvent = { export default meta; type Story = StoryObj; +const isEventLoading: Record = { + isDeletePostLoading: false, + isReportPostLoading: false, + isReportNicknameLoading: false, +}; + export const isWriterAndIsClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isNotWriterAndIsClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isWriterAndIsNotClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isNotWriterAndIsNotClosedCase: Story = { - render: () => , + render: () => ( + + ), }; diff --git a/frontend/src/pages/post/PostDetail/BottomButtonPart/index.tsx b/frontend/src/pages/post/PostDetail/BottomButtonPart/index.tsx index 3b8f83436..76964a8da 100644 --- a/frontend/src/pages/post/PostDetail/BottomButtonPart/index.tsx +++ b/frontend/src/pages/post/PostDetail/BottomButtonPart/index.tsx @@ -1,3 +1,5 @@ +import type { LoadingType } from '../types'; + import { useContext, useState } from 'react'; import { AuthContext } from '@hooks/context/auth'; @@ -9,7 +11,6 @@ import ReportModal from '@components/ReportModal'; import * as S from './style'; type MovePageEvent = 'moveWritePostPage' | 'moveVoteStatisticsPage' | 'movePostListPage'; - interface PostDetailPageChildProps { isWriter: boolean; isClosed: boolean; @@ -23,17 +24,19 @@ interface PostDetailPageChildProps { }; openToast: (text: string) => void; }; + isEventLoading: Record; } export default function BottomButtonPart({ isWriter, isClosed, handleEvent: { movePage, controlPost, openToast }, + isEventLoading, }: PostDetailPageChildProps) { const { loggedInfo } = useContext(AuthContext); const { moveWritePostPage, moveVoteStatisticsPage } = movePage; const { setEarlyClosePost, deletePost, reportPost, reportNickname } = controlPost; - + const { isDeletePostLoading, isReportPostLoading, isReportNicknameLoading } = isEventLoading; const [action, setAction] = useState(null); const handleActionButtonClick = (action: string) => { @@ -53,10 +56,18 @@ export default function BottomButtonPart({ {!isWriter ? ( <> - handleActionButtonClick('POST_REPORT')}> + handleActionButtonClick('POST_REPORT')} + > 게시물 신고 - handleActionButtonClick('NICKNAME_REPORT')}> + handleActionButtonClick('NICKNAME_REPORT')} + > 작성자 닉네임 신고 @@ -71,7 +82,7 @@ export default function BottomButtonPart({ handleActionButtonClick('DELETE')} > 삭 제 @@ -84,8 +95,9 @@ export default function BottomButtonPart({ handleActionButtonClick('DELETE')} + disabled={isDeletePostLoading} > 삭 제 @@ -96,6 +108,7 @@ export default function BottomButtonPart({ target="POST" handleCancelClick={handleCancelClick} handleDeleteClick={deletePost} + isDeleting={isDeletePostLoading} /> )} {action === 'POST_REPORT' && ( @@ -103,6 +116,7 @@ export default function BottomButtonPart({ reportType="POST" handleReportClick={reportPost} handleCancelClick={handleCancelClick} + isReportLoading={isReportPostLoading} /> )} {action === 'NICKNAME_REPORT' && ( @@ -110,6 +124,7 @@ export default function BottomButtonPart({ reportType="NICKNAME" handleReportClick={reportNickname} handleCancelClick={handleCancelClick} + isReportLoading={isReportNicknameLoading} /> )} diff --git a/frontend/src/pages/post/PostDetail/InnerHeaderPart/InnerHeaderPart.stories.tsx b/frontend/src/pages/post/PostDetail/InnerHeaderPart/InnerHeaderPart.stories.tsx index 9040e39bf..382c71f15 100644 --- a/frontend/src/pages/post/PostDetail/InnerHeaderPart/InnerHeaderPart.stories.tsx +++ b/frontend/src/pages/post/PostDetail/InnerHeaderPart/InnerHeaderPart.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; +import { LoadingType } from '../types'; + import InnerHeaderPart from '.'; const meta: Meta = { @@ -9,6 +11,15 @@ const meta: Meta = { decorators: [storyFn => {storyFn()}], }; +export default meta; +type Story = StoryObj; + +const isEventLoading: Record = { + isDeletePostLoading: false, + isReportPostLoading: false, + isReportNicknameLoading: false, +}; + const handleEvent = { movePage: { moveWritePostPage: () => {}, @@ -23,21 +34,46 @@ const handleEvent = { }, }; -export default meta; -type Story = StoryObj; - export const isWriterAndIsClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isNotWriterAndIsClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isWriterAndIsNotClosedCase: Story = { - render: () => , + render: () => ( + + ), }; export const isNotWriterAndIsNotClosedCase: Story = { - render: () => , + render: () => ( + + ), }; diff --git a/frontend/src/pages/post/PostDetail/InnerHeaderPart/index.tsx b/frontend/src/pages/post/PostDetail/InnerHeaderPart/index.tsx index 911dad334..6fdbd7164 100644 --- a/frontend/src/pages/post/PostDetail/InnerHeaderPart/index.tsx +++ b/frontend/src/pages/post/PostDetail/InnerHeaderPart/index.tsx @@ -12,6 +12,8 @@ import PostMenu from '@components/common/PostMenu'; import TagButton from '@components/common/TagButton'; import ReportModal from '@components/ReportModal'; +import { LoadingType } from '../types'; + import * as S from './style'; type MovePageEvent = 'moveWritePostPage' | 'moveVoteStatisticsPage' | 'movePostListPage'; @@ -28,6 +30,7 @@ interface PostDetailPageChildProps { reportNickname: (reason: string) => void; }; }; + isEventLoading: Record; } const menuList: PostMenuItem[] = [ @@ -39,6 +42,7 @@ export default function InnerHeaderPart({ isWriter, isClosed, handleEvent: { movePage, controlPost }, + isEventLoading, }: PostDetailPageChildProps) { const navigate = useNavigate(); @@ -47,6 +51,8 @@ export default function InnerHeaderPart({ const { isOpen, toggleComponent, closeComponent } = useToggle(); const [action, setAction] = useState(null); + const { isDeletePostLoading, isReportNicknameLoading, isReportPostLoading } = isEventLoading; + const handleMenuClick = (action: PostAction) => { closeComponent(); setAction(action); @@ -84,8 +90,12 @@ export default function InnerHeaderPart({ 수정 - handleMenuClick('DELETE')}> - 삭제 + handleMenuClick('DELETE')} + disabled={isDeletePostLoading} + > + {isDeletePostLoading ? '삭제 중...' : '삭제'} @@ -95,8 +105,13 @@ export default function InnerHeaderPart({ ) : ( <> - handleMenuClick('DELETE')}> - 삭제 + handleMenuClick('DELETE')} + disabled={isDeletePostLoading} + isLoading={isDeletePostLoading} + > + {isDeletePostLoading ? '삭제 중...' : '삭제'} @@ -110,6 +125,7 @@ export default function InnerHeaderPart({ target="POST" handleCancelClick={handleCancelClick} handleDeleteClick={deletePost} + isDeleting={isDeletePostLoading} /> )} {action === 'POST_REPORT' && ( @@ -117,6 +133,7 @@ export default function InnerHeaderPart({ reportType="POST" handleReportClick={reportPost} handleCancelClick={handleCancelClick} + isReportLoading={isReportPostLoading} /> )} {action === 'NICKNAME_REPORT' && ( @@ -124,6 +141,7 @@ export default function InnerHeaderPart({ reportType="NICKNAME" handleReportClick={reportNickname} handleCancelClick={handleCancelClick} + isReportLoading={isReportNicknameLoading} /> )} diff --git a/frontend/src/pages/post/PostDetail/InnerHeaderPart/style.ts b/frontend/src/pages/post/PostDetail/InnerHeaderPart/style.ts index 83c6e901b..4a091120a 100644 --- a/frontend/src/pages/post/PostDetail/InnerHeaderPart/style.ts +++ b/frontend/src/pages/post/PostDetail/InnerHeaderPart/style.ts @@ -1,5 +1,7 @@ import { styled } from 'styled-components'; +import { theme } from '@styles/theme'; + export const HeaderWrapper = styled.div` display: flex; gap: 30px; @@ -19,4 +21,6 @@ export const MenuWrapper = styled.div` position: absolute; top: 45px; right: 10px; + + z-index: ${theme.zIndex.modal}; `; diff --git a/frontend/src/pages/post/PostDetail/PostDetail/index.tsx b/frontend/src/pages/post/PostDetail/PostDetail/index.tsx index 2285ed922..73fa6639a 100644 --- a/frontend/src/pages/post/PostDetail/PostDetail/index.tsx +++ b/frontend/src/pages/post/PostDetail/PostDetail/index.tsx @@ -1,4 +1,4 @@ -import { Suspense, useContext, useEffect } from 'react'; +import { Suspense, useContext, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { PostInfo } from '@type/post'; @@ -18,10 +18,13 @@ import CommentList from '@components/comment/CommentList'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; import Post from '@components/common/Post'; import Skeleton from '@components/common/Skeleton'; +import TagButton from '@components/common/TagButton'; import Toast from '@components/common/Toast'; import { checkClosedPost } from '@utils/time'; +import copyURL from '@assets/chain.svg'; + import BottomButtonPart from '../BottomButtonPart'; import InnerHeaderPart from '../InnerHeaderPart'; @@ -30,6 +33,9 @@ import * as S from './style'; export default function PostDetail() { const navigate = useNavigate(); + const [isReportPostLoading, setIsReportPostLoading] = useState(false); + const [isReportNicknameLoading, setIsReportNicknameLoading] = useState(false); + const params = useParams() as { postId: string }; const postId = Number(params.postId); const { isToastOpen, openToast, toastMessage } = useToast(); @@ -43,6 +49,7 @@ export default function PostDetail() { isSuccess: isDeleteSuccess, isError: isDeleteError, error: deleteError, + isLoading: isDeletePostLoading, } = useDeletePost(postId, loggedInfo.isLoggedIn); const { mutate: earlyClosePost } = useEarlyClosePost(postId); @@ -79,6 +86,7 @@ export default function PostDetail() { deletePost(); }, reportPost: async (reason: string) => { + setIsReportPostLoading(true); const reportData: ReportRequest = { type: 'POST', id: postId, reason }; await reportContent(reportData) @@ -92,9 +100,13 @@ export default function PostDetail() { return; } openToast('게시글 신고가 실패했습니다.'); + }) + .finally(() => { + setIsReportPostLoading(false); }); }, reportNickname: async (reason: string) => { + setIsReportNicknameLoading(true); const reportData: ReportRequest = { type: 'NICKNAME', id: postDataFallback.writer.id, @@ -112,6 +124,21 @@ export default function PostDetail() { return; } openToast('작성자 닉네임 신고가 실패했습니다.'); + }) + .finally(() => { + setIsReportNicknameLoading(false); + }); + }, + copyPostURL: () => { + const currentURL = window.location.href; + navigator.clipboard + .writeText(currentURL) + .then(() => { + openToast('게시물 URL이 클립보드에 복사되었습니다.'); + }) + .catch(error => { + console.error('URL 복사 실패:', error); + openToast('URL을 클립보드에 복사하는 동안 오류가 발생했습니다. 다시 시도해주세요.'); }); }, }; @@ -136,15 +163,30 @@ export default function PostDetail() { isClosed={isClosed} isWriter={isWriter} handleEvent={{ movePage, controlPost }} + isEventLoading={{ + isDeletePostLoading, + isReportPostLoading, + isReportNicknameLoading, + }} /> + + + 링크 복사 아이콘 + + diff --git a/frontend/src/pages/post/PostDetail/PostDetail/style.ts b/frontend/src/pages/post/PostDetail/PostDetail/style.ts index d22bc7688..82dfbeba0 100644 --- a/frontend/src/pages/post/PostDetail/PostDetail/style.ts +++ b/frontend/src/pages/post/PostDetail/PostDetail/style.ts @@ -30,3 +30,16 @@ export const BottomContainer = styled.div` margin: 10px; margin-bottom: 30px; `; + +export const TagButtonWrapper = styled.div<{ $isWriter: boolean }>` + position: fixed; + top: 70px; + right: 7%; + + @media (max-width: ${theme.breakpoint.sm}) { + top: 55px; + right: ${props => (props.$isWriter ? '28%' : '7%')}; + } + + z-index: ${theme.zIndex.tagButton}; +`; diff --git a/frontend/src/pages/post/PostDetail/index.tsx b/frontend/src/pages/post/PostDetail/index.tsx index 79eb1e7cc..dde35da98 100644 --- a/frontend/src/pages/post/PostDetail/index.tsx +++ b/frontend/src/pages/post/PostDetail/index.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import ErrorBoundaryWithNarrowHeader from '@pages/ErrorBoundaryWithNarrowHeader'; +import ErrorBoundary from '@pages/ErrorBoundary'; import Layout from '@components/common/Layout'; @@ -10,11 +10,11 @@ import PostDetailFallback from './PostDetailFallback'; export default function PostDetailPage() { return ( - + }> - + ); } diff --git a/frontend/src/pages/post/PostDetail/types.ts b/frontend/src/pages/post/PostDetail/types.ts new file mode 100644 index 000000000..582a909e9 --- /dev/null +++ b/frontend/src/pages/post/PostDetail/types.ts @@ -0,0 +1 @@ +export type LoadingType = 'isDeletePostLoading' | 'isReportPostLoading' | 'isReportNicknameLoading'; diff --git a/frontend/src/styles/globalStyle.ts b/frontend/src/styles/globalStyle.ts index 107e4a39f..6ed9b3b6f 100644 --- a/frontend/src/styles/globalStyle.ts +++ b/frontend/src/styles/globalStyle.ts @@ -40,8 +40,8 @@ export const GlobalStyle = createGlobalStyle` /* Fonts *****************************************/ --text-title: 600 2rem/2.4rem san-serif; --text-subtitle: 600 1.8rem/2.8rem san-serif; - --text-body: 400 1.6rem/2.4rem san-serif; - --text-caption: 400 1.4rem/2rem san-serif; + --text-body: 400 1.7rem/2.4rem san-serif; + --text-caption: 400 1.6rem/2rem san-serif; --text-small: 400 1.2rem/1.8rem san-serif; } `; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index e751d42d2..2c16ff711 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -11,6 +11,7 @@ const breakpoint = { const zIndex = { select: 1, + tagButton: 90, header: 100, modal: 200, }; diff --git a/frontend/src/types/post.ts b/frontend/src/types/post.ts index 44dd2ede5..3e35c3dd3 100644 --- a/frontend/src/types/post.ts +++ b/frontend/src/types/post.ts @@ -24,6 +24,8 @@ export interface PostInfo { imageUrl: string; category: { id: number; name: string }[]; createTime: string; + imageCount: number; + commentCount: number; deadline: string; voteInfo: { selectedOptionId: number; @@ -41,6 +43,8 @@ export interface PostInfoResponse { categories: { id: number; name: string }[]; createdAt: string; deadline: string; + imageCount: number; + commentCount: number; voteInfo: { selectedOptionId: number; totalVoteCount: number; @@ -65,3 +69,9 @@ export interface PostListByOptionalOption { categoryId: number; keyword: string; } + +export interface Time { + day: number; + hour: number; + minute: number; +} diff --git a/frontend/src/utils/post/convertImageUrlToServerUrl.ts b/frontend/src/utils/post/convertImageUrlToServerUrl.ts deleted file mode 100644 index ad991319e..000000000 --- a/frontend/src/utils/post/convertImageUrlToServerUrl.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IMAGE_BASE_URL } from '@constants/post'; - -export const convertImageUrlToServerUrl = (imageUrl: string) => { - return `${IMAGE_BASE_URL}${imageUrl}`; -}; - -export const convertServerUrlToImageUrl = (imageUrl: string) => { - return imageUrl.replace(IMAGE_BASE_URL, ''); -}; diff --git a/frontend/src/utils/post/deleteOverlappingNewLine.ts b/frontend/src/utils/post/deleteOverlappingNewLine.ts new file mode 100644 index 000000000..65bb26e20 --- /dev/null +++ b/frontend/src/utils/post/deleteOverlappingNewLine.ts @@ -0,0 +1,3 @@ +export const deleteOverlappingNewLine = (text: string) => { + return text.replace(/(\n{5,})/g, '\n\n\n\n\n'); +}; diff --git a/frontend/src/utils/post/formatContentLink.tsx b/frontend/src/utils/post/formatTextLink.tsx similarity index 100% rename from frontend/src/utils/post/formatContentLink.tsx rename to frontend/src/utils/post/formatTextLink.tsx diff --git a/frontend/src/utils/post/formatTime.ts b/frontend/src/utils/post/formatTime.ts index 8353817c7..15d185224 100644 --- a/frontend/src/utils/post/formatTime.ts +++ b/frontend/src/utils/post/formatTime.ts @@ -1,12 +1,7 @@ -interface Time { - day: number; - hour: number; - minute: number; -} +import { Time } from '@type/post'; export function addTimeToDate(addTime: Time, baseTime: Date) { const { day, hour, minute } = addTime; - if (day === 0 && hour === 0 && minute === 0) return; const newTime = new Date(baseTime); @@ -22,11 +17,3 @@ export function addTimeToDate(addTime: Time, baseTime: Date) { return `${newYear}-${newMonth}-${newDay} ${newHour}:${newMinute}`; } - -export function formatTimeWithOption(option: string) { - if (option === '10분') return { day: 0, hour: 0, minute: 10 }; - else if (option === '30분') return { day: 0, hour: 0, minute: 30 }; - else if (option === '1시간') return { day: 0, hour: 1, minute: 0 }; - else if (option === '6시간') return { day: 0, hour: 6, minute: 0 }; - else return { day: 1, hour: 0, minute: 0 }; -} diff --git a/frontend/src/utils/post/getDeadlineTime.ts b/frontend/src/utils/post/getDeadlineTime.ts index 4952300ff..d6675c31e 100644 --- a/frontend/src/utils/post/getDeadlineTime.ts +++ b/frontend/src/utils/post/getDeadlineTime.ts @@ -1,12 +1,6 @@ -export const getDeadlineTime = ({ - day, - hour, - minute, -}: { - day: number; - hour: number; - minute: number; -}) => { +import { Time } from '@type/post'; + +export const getDeadlineTime = ({ day, hour, minute }: Time) => { const timeMessage = []; if (day < 0 || hour < 0 || minute < 0) { diff --git a/frontend/src/utils/post/getSelectedTimeOption.ts b/frontend/src/utils/post/getSelectedTimeOption.ts index 6729b144c..d5c0e2c6e 100644 --- a/frontend/src/utils/post/getSelectedTimeOption.ts +++ b/frontend/src/utils/post/getSelectedTimeOption.ts @@ -1,20 +1,13 @@ -import { DeadlineOption } from '@components/PostForm/constants'; +import { Time } from '@type/post'; -export const getSelectedTimeOption = ({ - day, - hour, - minute, -}: { - day: number; - hour: number; - minute: number; -}): DeadlineOption | '사용자지정' | null => { - if (day === 0 && hour === 0 && minute === 0) return null; - if (day === 0 && hour === 0 && minute === 10) return '10분'; - if (day === 0 && hour === 0 && minute === 30) return '30분'; - if (day === 0 && hour === 1 && minute === 0) return '1시간'; - if (day === 0 && hour === 6 && minute === 0) return '6시간'; - if (day === 1 && hour === 0 && minute === 0) return '1일'; +import { DEADLINE_OPTION, DeadlineOptionName } from '@components/PostForm/constants'; - return '사용자지정'; +export const getSelectedTimeOption = (time: Time): DeadlineOptionName | '사용자지정' | null => { + if (time.day === 0 && time.hour === 0 && time.minute === 0) return null; + + const stringTime = JSON.stringify(time); + + return ( + DEADLINE_OPTION.find(option => JSON.stringify(option.time) === stringTime)?.name ?? '사용자지정' + ); }; diff --git a/frontend/src/utils/post/uploadImage.ts b/frontend/src/utils/post/uploadImage.ts new file mode 100644 index 000000000..942794cb6 --- /dev/null +++ b/frontend/src/utils/post/uploadImage.ts @@ -0,0 +1,38 @@ +import { MAX_FILE_SIZE } from '@constants/post'; + +import { convertImageToWebP } from '@utils/resizeImage'; + +export const uploadImage = async ({ + imageFile, + inputElement, + setPreviewImageUrl, +}: { + imageFile: File; + inputElement: HTMLInputElement | null; + setPreviewImageUrl: (previewUrl: string) => void; +}) => { + if (!inputElement) return; + + const webpFileList = await convertImageToWebP(imageFile); + + inputElement.files = webpFileList; + + const reader = new FileReader(); + + const webpFile = webpFileList[0]; + + reader.readAsDataURL(webpFile); + + inputElement.setCustomValidity(''); + + if (imageFile.size > MAX_FILE_SIZE) { + inputElement.setCustomValidity('사진의 용량은 10MB 이하만 가능합니다.'); + inputElement.reportValidity(); + + return; + } + + reader.onloadend = () => { + setPreviewImageUrl(reader.result?.toString() ?? ''); + }; +}; diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts index 6be18c6b3..4da6783e8 100644 --- a/frontend/src/utils/time.ts +++ b/frontend/src/utils/time.ts @@ -1,3 +1,5 @@ +import { MAX_DEADLINE } from '@constants/post'; + import { addTimeToDate } from './post/formatTime'; const convertNowTimeToNumber = () => { @@ -16,6 +18,7 @@ const convertTimeFromStringToNumber = (date: string) => { const dateComponents = date.split(' '); const datePieces = dateComponents[0].split('-'); const timePieces = dateComponents[1].split(':'); + return Number([...datePieces, ...timePieces].join('')); }; @@ -29,40 +32,48 @@ type TimeType = 'day' | 'hour' | 'minute'; //시간 수정을 할 수 없다면 true export const checkIrreplaceableTime = (addTime: Record, createTime: string) => { - const changedDeadline = addTimeToDate(addTime, new Date(createTime)); - // changedDeadline가 undefined인 경우는 작성일시에서 시간이 더해지지 않았을 경우라 거절 - if (!changedDeadline) return true; + const transCreateTime = createTime.split('-').join('/'); + const changedDeadline = addTimeToDate(addTime, new Date(transCreateTime)); - const limitDeadline = addTimeToDate({ day: 3, hour: 0, minute: 0 }, new Date(createTime))!; + //마감시한이 0시간 0분 0초 추가된다면 거절 + if (Object.values(addTime).every(time => time === 0)) return true; + + const limitDeadline = addTimeToDate( + { day: MAX_DEADLINE, hour: 0, minute: 0 }, + new Date(transCreateTime) + )!; const changedDeadlineNumber = convertTimeFromStringToNumber(changedDeadline); const limitDeadlineNumber = convertTimeFromStringToNumber(limitDeadline); - //작성일시로부터 3일된 일시보다 지정하고자 하는 일시가 크다면 거절 - if (changedDeadlineNumber >= limitDeadlineNumber) return true; + //작성일시로부터 마감시간 최대일시보다 지정하고자 하는 일시가 크다면 거절 + if (changedDeadlineNumber > limitDeadlineNumber) return true; //지금 일시보다 지정하고자 하는 일시가 작다면 거절 return changedDeadlineNumber <= convertNowTimeToNumber(); }; const time = { - day: 3, hour: 24, minute: 60, }; -export const convertTimeToWord = (date: string) => { - const targetDate = new Date(date); - const currentDate = new Date(); +export const convertTimeToWord = (date: string, currentDate: Date = new Date()) => { + const targetDate = new Date(date.split('-').join('/')); //분 단위로 산출됨 const timeDifference = Math.floor((targetDate.getTime() - currentDate.getTime()) / 60000); if (timeDifference === 0) return '지금'; - const afterBefore = timeDifference > 0 ? '후 마감' : '전 작성 |'; + const afterBefore = timeDifference > 0 ? '후 마감' : '전 작성'; const positiveTimeDifference = Math.abs(timeDifference); + if (Math.round(positiveTimeDifference / (time.hour * time.minute)) > 0) { + const day = Math.round(positiveTimeDifference / (time.hour * time.minute)); + return day >= 30 ? `${date.split(' ')[0]}` : `${day}일 ${afterBefore}`; + } + if (Math.round(positiveTimeDifference / (time.hour * time.minute)) > 0) return `${Math.round(positiveTimeDifference / (time.hour * time.minute))}일 ${afterBefore}`; diff --git a/frontend/src/utils/token/silentLogin.ts b/frontend/src/utils/token/silentLogin.ts index 5d4b88bca..4976335d5 100644 --- a/frontend/src/utils/token/silentLogin.ts +++ b/frontend/src/utils/token/silentLogin.ts @@ -6,11 +6,15 @@ import { getLocalStorage, removeLocalStorage, setLocalStorage } from '../localSt import { isRefreshTokenRequested } from './isRefreshTokenRequested'; +let isRequest = false; + export const silentLogin = async () => { - if (!isRefreshTokenRequested()) { + if (!isRefreshTokenRequested() || isRequest) { return; } + isRequest = true; + try { const accessToken = getLocalStorage(ACCESS_TOKEN_KEY); @@ -26,5 +30,7 @@ export const silentLogin = async () => { window.location.href = '/login'; throw new Error('로그인에 실패했습니다. 다시 로그인 해주세요.'); + } finally { + isRequest = false; } }; diff --git a/frontend/webpack.analyzer.js b/frontend/webpack.analyzer.js new file mode 100644 index 000000000..96e0c72a0 --- /dev/null +++ b/frontend/webpack.analyzer.js @@ -0,0 +1,10 @@ +const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const { merge } = require('webpack-merge'); + +const common = require('./webpack.common.js'); + +module.exports = merge(common, { + plugins: [new BundleAnalyzerPlugin()], + mode: 'production', // 현재 배포 모드 + devtool: 'hidden-source-map', // 느리지만 안전 배포에 추천 +}); diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 7ac442629..bddb6eccf 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -62,7 +62,7 @@ module.exports = { type: 'asset/inline', }, { - test: /\.(png|jpg|jpeg|gif)$/i, + test: /\.(png|jpg|jpeg|gif|webp)$/i, type: 'asset/resource', }, ], @@ -93,5 +93,14 @@ module.exports = { target: 'es2021', }), ], + splitChunks: { + cacheGroups: { + react: { + test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, + name: 'react', + chunks: 'all', + }, + }, + }, }, }; diff --git a/frontend/webpack.https.js b/frontend/webpack.https.js new file mode 100644 index 000000000..bca1f97b0 --- /dev/null +++ b/frontend/webpack.https.js @@ -0,0 +1,20 @@ +const { merge } = require('webpack-merge'); + +const common = require('./webpack.common.js'); + +module.exports = merge(common, { + mode: 'development', // 현재 개발 모드 + devtool: 'eval', // 최대성능, 개발환경에 추천 + devServer: { + historyApiFallback: true, + port: 3000, + hot: true, + server: { + type: 'https', + options: { + key: './localhost-key.pem', + cert: './localhost.pem', + }, + }, + }, +});