diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java b/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java index f779766a7..fb349fe00 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java @@ -6,7 +6,7 @@ import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; import konkuk.thip.comment.domain.Comment; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.common.post.service.PostHandler; import konkuk.thip.common.post.PostType; import lombok.RequiredArgsConstructor; @@ -34,7 +34,7 @@ public Long createComment(CommentCreateCommand command) { PostType type = PostType.from(command.postType()); // 2. 게시물 타입에 맞게 조회 - CommentCountUpdatable post = postHandler.findPost(type, command.postId()); + CountUpdatable post = postHandler.findPost(type, command.postId()); // 2-1. 게시글 타입에 따른 댓글 생성 권한 검증 commentAuthorizationValidator.validateUserCanAccessPostForComment(type, post, command.userId()); diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentDeleteService.java b/src/main/java/konkuk/thip/comment/application/service/CommentDeleteService.java index c85e522e7..1199ad850 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentDeleteService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentDeleteService.java @@ -6,7 +6,7 @@ import konkuk.thip.comment.application.port.out.CommentLikeCommandPort; import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; import konkuk.thip.comment.domain.Comment; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.common.post.service.PostHandler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -28,7 +28,7 @@ public Long deleteComment(Long commentId, Long userId) { // 1. 댓글 조회 및 권한 검증 Comment comment = commentCommandPort.getByIdOrThrow(commentId); // 1-1. 게시글 타입에 따른 댓글 삭제 권한 검증 - CommentCountUpdatable post = postHandler.findPost(comment.getPostType(), comment.getTargetPostId()); + CountUpdatable post = postHandler.findPost(comment.getPostType(), comment.getTargetPostId()); commentAuthorizationValidator.validateUserCanAccessPostForComment(comment.getPostType(), post, userId); // 2. 댓글 삭제 권한 검증 및 소프트 딜리트 diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java b/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java index cf601a84f..e497ab55f 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java @@ -9,7 +9,7 @@ import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; import konkuk.thip.comment.domain.Comment; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.common.post.service.PostHandler; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -32,7 +32,7 @@ public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) // 1. 댓글 조회 및 검증 (존재 여부) Comment comment = commentCommandPort.getByIdOrThrow(command.commentId()); // 1-1. 게시글 타입에 따른 댓글 좋아요 권한 검증 - CommentCountUpdatable post = postHandler.findPost(comment.getPostType(), comment.getTargetPostId()); + CountUpdatable post = postHandler.findPost(comment.getPostType(), comment.getTargetPostId()); commentAuthorizationValidator.validateUserCanAccessPostForComment(comment.getPostType(), post, command.userId()); // 2. 유저가 해당 댓글에 대해 좋아요 했는지 조회 diff --git a/src/main/java/konkuk/thip/comment/application/service/policy/CommentAccessPolicy.java b/src/main/java/konkuk/thip/comment/application/service/policy/CommentAccessPolicy.java index c9a5b8e96..0e91edd00 100644 --- a/src/main/java/konkuk/thip/comment/application/service/policy/CommentAccessPolicy.java +++ b/src/main/java/konkuk/thip/comment/application/service/policy/CommentAccessPolicy.java @@ -1,7 +1,7 @@ package konkuk.thip.comment.application.service.policy; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; public interface CommentAccessPolicy { - void validateCommentAccess(CommentCountUpdatable post, Long userId); + void validateCommentAccess(CountUpdatable post, Long userId); } diff --git a/src/main/java/konkuk/thip/comment/application/service/policy/FeedCommentAccessPolicy.java b/src/main/java/konkuk/thip/comment/application/service/policy/FeedCommentAccessPolicy.java index c53badbd0..b553041fd 100644 --- a/src/main/java/konkuk/thip/comment/application/service/policy/FeedCommentAccessPolicy.java +++ b/src/main/java/konkuk/thip/comment/application/service/policy/FeedCommentAccessPolicy.java @@ -1,6 +1,6 @@ package konkuk.thip.comment.application.service.policy; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.feed.domain.Feed; import org.springframework.stereotype.Component; @@ -8,7 +8,7 @@ public class FeedCommentAccessPolicy implements CommentAccessPolicy { @Override - public void validateCommentAccess(CommentCountUpdatable post, Long userId) { + public void validateCommentAccess(CountUpdatable post, Long userId) { Feed feed = (Feed) post; feed.validateCreateComment(userId); diff --git a/src/main/java/konkuk/thip/comment/application/service/policy/RoomPostCommentAccessPolicy.java b/src/main/java/konkuk/thip/comment/application/service/policy/RoomPostCommentAccessPolicy.java index 125c9b7e6..f8a93b11b 100644 --- a/src/main/java/konkuk/thip/comment/application/service/policy/RoomPostCommentAccessPolicy.java +++ b/src/main/java/konkuk/thip/comment/application/service/policy/RoomPostCommentAccessPolicy.java @@ -1,6 +1,6 @@ package konkuk.thip.comment.application.service.policy; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.room.application.service.validator.RoomParticipantValidator; import konkuk.thip.room.domain.RoomPost; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ public class RoomPostCommentAccessPolicy implements CommentAccessPolicy { private final RoomParticipantValidator roomParticipantValidator; @Override - public void validateCommentAccess(CommentCountUpdatable post, Long userId) { + public void validateCommentAccess(CountUpdatable post, Long userId) { RoomPost roomPost = (RoomPost) post; roomParticipantValidator.validateUserIsRoomMember(roomPost.getRoomId(), userId); } diff --git a/src/main/java/konkuk/thip/comment/application/service/validator/CommentAuthorizationValidator.java b/src/main/java/konkuk/thip/comment/application/service/validator/CommentAuthorizationValidator.java index 6d6be7c9d..63fad7cb0 100644 --- a/src/main/java/konkuk/thip/comment/application/service/validator/CommentAuthorizationValidator.java +++ b/src/main/java/konkuk/thip/comment/application/service/validator/CommentAuthorizationValidator.java @@ -2,7 +2,7 @@ import konkuk.thip.comment.application.service.policy.CommentAccessPolicy; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.common.post.PostType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,14 +15,14 @@ @RequiredArgsConstructor public class CommentAuthorizationValidator { - private final Map policyMap; + private final Map commentAccessPolicyMap; - public void validateUserCanAccessPostForComment(PostType type, CommentCountUpdatable post, Long userId) { + public void validateUserCanAccessPostForComment(PostType type, CountUpdatable post, Long userId) { getPolicy(type).validateCommentAccess(post, userId); } private CommentAccessPolicy getPolicy(PostType type) { - CommentAccessPolicy policy = policyMap.get(type); + CommentAccessPolicy policy = commentAccessPolicyMap.get(type); if (policy == null) { throw new InvalidStateException(POST_TYPE_NOT_MATCH); } diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index bf9831087..4d6b735da 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -91,6 +91,7 @@ public enum ErrorCode implements ResponseCode { ROOM_MEMBER_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, 100006, "방의 최대 인원 수를 초과했습니다."), ROOM_MEMBER_COUNT_UNDERFLOW(HttpStatus.BAD_REQUEST, 100007, "방의 인원 수가 1 이하(방장 포함)입니다."), ROOM_IS_EXPIRED(HttpStatus.BAD_REQUEST, 100008, "방이 만료되었습니다."), + ROOM_POST_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 100009, "일치하는 방 게시물 타입 이름이 없습니다. [RECORD, VOTE] 중 하나여야 합니다."), /** * 110000 : vote error @@ -167,6 +168,14 @@ public enum ErrorCode implements ResponseCode { */ POST_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 180000, "일치하는 게시물 타입 이름이 없습니다. [FEED, RECORD, VOTE] 중 하나여야 합니다."), + /** + * 185000 : PostLike error + * + */ + POST_LIKE_COUNT_UNDERFLOW(HttpStatus.BAD_REQUEST, 185000, "좋아요 수는 0 이하로 감소할 수 없습니다."), + POST_ALREADY_LIKED(HttpStatus.BAD_REQUEST, 185001, "사용자가 이미 좋아요한 게시물입니다."), + POST_NOT_LIKED_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, 185002, "사용자가 좋아요하지 않은 게시물은 좋아요 취소 할 수 없습니다."), + /** * 190000 : Comment error */ diff --git a/src/main/java/konkuk/thip/common/post/CommentCountUpdatable.java b/src/main/java/konkuk/thip/common/post/CommentCountUpdatable.java deleted file mode 100644 index accccc90e..000000000 --- a/src/main/java/konkuk/thip/common/post/CommentCountUpdatable.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.common.post; - -public interface CommentCountUpdatable { - void increaseCommentCount(); - void decreaseCommentCount(); - Long getId(); -} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/common/post/CountUpdatable.java b/src/main/java/konkuk/thip/common/post/CountUpdatable.java new file mode 100644 index 000000000..614e68253 --- /dev/null +++ b/src/main/java/konkuk/thip/common/post/CountUpdatable.java @@ -0,0 +1,10 @@ +package konkuk.thip.common.post; + +import konkuk.thip.post.domain.service.PostCountService; + +public interface CountUpdatable { //TODO 패키지 구조 충돌안나게 한번에 옮기기 + void increaseCommentCount(); + void decreaseCommentCount(); + void updateLikeCount(PostCountService postCountService, boolean isLike); + Long getId(); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/common/post/PostType.java b/src/main/java/konkuk/thip/common/post/PostType.java index 10875289e..b17f35e27 100644 --- a/src/main/java/konkuk/thip/common/post/PostType.java +++ b/src/main/java/konkuk/thip/common/post/PostType.java @@ -26,16 +26,4 @@ public static PostType from(String type) { new InvalidStateException(POST_TYPE_NOT_MATCH) ); } - - public boolean isFeed() { - return this == FEED; - } - - public boolean isRecord() { - return this == RECORD; - } - - public boolean isVote() { - return this == VOTE; - } } diff --git a/src/main/java/konkuk/thip/common/post/service/PostHandler.java b/src/main/java/konkuk/thip/common/post/service/PostHandler.java index 06428b418..7dce5f1f5 100644 --- a/src/main/java/konkuk/thip/common/post/service/PostHandler.java +++ b/src/main/java/konkuk/thip/common/post/service/PostHandler.java @@ -1,7 +1,7 @@ package konkuk.thip.common.post.service; import konkuk.thip.common.annotation.HelperService; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; import konkuk.thip.common.post.PostType; import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.domain.Feed; @@ -19,7 +19,7 @@ public class PostHandler { private final RecordCommandPort recordCommandPort; private final VoteCommandPort voteCommandPort; - public CommentCountUpdatable findPost(PostType type, Long postId) { + public CountUpdatable findPost(PostType type, Long postId) { return switch (type) { case FEED -> feedCommandPort.getByIdOrThrow(postId); case RECORD -> recordCommandPort.getByIdOrThrow(postId); @@ -27,7 +27,7 @@ public CommentCountUpdatable findPost(PostType type, Long postId) { }; } - public void updatePost(PostType type, CommentCountUpdatable post) { + public void updatePost(PostType type, CountUpdatable post) { switch (type) { case FEED -> feedCommandPort.update((Feed) post); case RECORD -> recordCommandPort.update((Record) post); diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 54be1162f..2b3d6ec96 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -104,6 +104,16 @@ public enum SwaggerResponseDescription { ROOM_GET_DEADLINE_POPULAR(new LinkedHashSet<>(Set.of( CATEGORY_NOT_MATCH ))), + CHANGE_ROOM_LIKE_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + RECORD_NOT_FOUND, + VOTE_NOT_FOUND, + POST_ALREADY_LIKED, + POST_NOT_LIKED_CANNOT_CANCEL, + POST_LIKE_COUNT_UNDERFLOW, + ROOM_ACCESS_FORBIDDEN, + ROOM_POST_TYPE_NOT_MATCH + ))), // Record @@ -174,6 +184,14 @@ public enum SwaggerResponseDescription { BOOK_NOT_FOUND, FEED_CAN_NOT_SHOW_PRIVATE_ONE ))), + CHANGE_FEED_LIKE_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + FEED_NOT_FOUND, + POST_ALREADY_LIKED, + POST_NOT_LIKED_CANNOT_CANCEL, + POST_LIKE_COUNT_UNDERFLOW, + FEED_ACCESS_FORBIDDEN + ))), // Comment COMMENT_CREATE(new LinkedHashSet<>(Set.of( diff --git a/src/main/java/konkuk/thip/config/CommentAccessPolicyConfig.java b/src/main/java/konkuk/thip/config/PostAccessPolicyConfig.java similarity index 58% rename from src/main/java/konkuk/thip/config/CommentAccessPolicyConfig.java rename to src/main/java/konkuk/thip/config/PostAccessPolicyConfig.java index ddf53ceed..e2491cb47 100644 --- a/src/main/java/konkuk/thip/config/CommentAccessPolicyConfig.java +++ b/src/main/java/konkuk/thip/config/PostAccessPolicyConfig.java @@ -4,6 +4,9 @@ import konkuk.thip.comment.application.service.policy.FeedCommentAccessPolicy; import konkuk.thip.comment.application.service.policy.RoomPostCommentAccessPolicy; import konkuk.thip.common.post.PostType; +import konkuk.thip.post.application.service.policy.FeedLikeAccessPolicy; +import konkuk.thip.post.application.service.policy.PostLikeAccessPolicy; +import konkuk.thip.post.application.service.policy.RoomPostLikeAccessPolicy; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,11 +15,14 @@ @Configuration @RequiredArgsConstructor -public class CommentAccessPolicyConfig { +public class PostAccessPolicyConfig { private final FeedCommentAccessPolicy feedCommentPolicy; private final RoomPostCommentAccessPolicy roomCommentPolicy; + private final FeedLikeAccessPolicy feedLikePolicy; + private final RoomPostLikeAccessPolicy roomPostLikePolicy; + @Bean public Map commentAccessPolicyMap() { return Map.of( @@ -25,4 +31,13 @@ public Map commentAccessPolicyMap() { PostType.VOTE, roomCommentPolicy ); } + + @Bean + public Map postLikeAccessPolicyMap() { + return Map.of( + PostType.FEED, feedLikePolicy, + PostType.RECORD, roomPostLikePolicy, + PostType.VOTE, roomPostLikePolicy + ); + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/config/SecurityConfig.java b/src/main/java/konkuk/thip/config/SecurityConfig.java index beb014447..d187ea5f9 100644 --- a/src/main/java/konkuk/thip/config/SecurityConfig.java +++ b/src/main/java/konkuk/thip/config/SecurityConfig.java @@ -35,6 +35,10 @@ public class SecurityConfig { @Value("${server.web-domain-url}") private String webDomainUrl; + @Value("${server.https-url}") + private String httpsUrl; + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomOAuth2UserService customOAuth2UserService; @@ -68,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) .exceptionHandling(handler -> handler.authenticationEntryPoint(jwtAuthenticationEntryPoint)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - ; + ; return http.build(); } @@ -90,7 +94,8 @@ public CorsConfigurationSource corsConfigurationSource() { config.setAllowedOrigins(List.of( "http://localhost:5173", webUrl, - webDomainUrl + webDomainUrl, + httpsUrl )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(Collections.singletonList("*")); @@ -104,5 +109,5 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } - + } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java index 70970ec72..0921d3ec2 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java @@ -8,13 +8,16 @@ import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.feed.adapter.in.web.request.FeedCreateRequest; +import konkuk.thip.feed.adapter.in.web.request.FeedIsLikeRequest; import konkuk.thip.feed.adapter.in.web.request.FeedIsSavedRequest; import konkuk.thip.feed.adapter.in.web.request.FeedUpdateRequest; import konkuk.thip.feed.adapter.in.web.response.FeedIdResponse; +import konkuk.thip.feed.adapter.in.web.response.FeedIsLikeResponse; import konkuk.thip.feed.adapter.in.web.response.FeedIsSavedResponse; import konkuk.thip.feed.application.port.in.FeedCreateUseCase; import konkuk.thip.feed.application.port.in.FeedSavedUseCase; import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; +import konkuk.thip.post.application.port.in.PostLikeUseCase; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -31,6 +34,7 @@ public class FeedCommandController { private final FeedCreateUseCase feedCreateUseCase; private final FeedUpdateUseCase feedUpdateUseCase; private final FeedSavedUseCase feedSavedUseCase; + private final PostLikeUseCase postLikeUseCase; @Operation( summary = "피드 작성", @@ -74,4 +78,17 @@ public BaseResponse changeSavedFeed( return BaseResponse.ok(FeedIsSavedResponse.of(feedSavedUseCase.changeSavedFeed(FeedIsSavedRequest.toCommand(userId,feedId,request.type())))); } + @Operation( + summary = "피드 좋아요 상태 변경", + description = "사용자가 피드의 좋아요 상태를 변경합니다. (true -> 좋아요, false -> 좋아요 취소)" + ) + @ExceptionDescription(CHANGE_FEED_LIKE_STATE) + @PostMapping("/feeds/{feedId}/likes") + public BaseResponse likeFeed( + @RequestBody @Valid final FeedIsLikeRequest request, + @Parameter(description = "좋아요 상태를 변경하려는 피드 ID", example = "1")@PathVariable("feedId") final Long feedId, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(FeedIsLikeResponse.of(postLikeUseCase.changeLikeStatusPost(request.toCommand(userId, feedId)))); + } + } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsLikeRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsLikeRequest.java new file mode 100644 index 000000000..fdebfc734 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsLikeRequest.java @@ -0,0 +1,18 @@ +package konkuk.thip.feed.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; + +import static konkuk.thip.common.post.PostType.FEED; + +@Schema(description = "피드 좋아요 상태 변경 요청 DTO") +public record FeedIsLikeRequest( + @Schema(description = "좋아요 여부 type (true -> 좋아요, false -> 좋아요 취소)", example = "true") + @NotNull(message = "좋아요 여부는 필수입니다.") + Boolean type +) { + public PostIsLikeCommand toCommand(Long userId, Long feedId) { + return new PostIsLikeCommand(userId, feedId, FEED ,type); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsLikeResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsLikeResponse.java new file mode 100644 index 000000000..6d5e58cc2 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsLikeResponse.java @@ -0,0 +1,12 @@ +package konkuk.thip.feed.adapter.in.web.response; + +import konkuk.thip.post.application.port.in.dto.PostIsLikeResult; + +public record FeedIsLikeResponse( + Long feedId, + boolean isLiked +) { + public static FeedIsLikeResponse of(PostIsLikeResult postIsLikeResult) { + return new FeedIsLikeResponse(postIsLikeResult.postId(), postIsLikeResult.isLiked()); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java index 648ec3868..0b3dffbc6 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java @@ -56,4 +56,9 @@ public void updateCommentCount(int commentCount) { this.commentCount = commentCount; } + @VisibleForTesting + public void updateLikeCount(int likeCount) { + this.likeCount = likeCount; + } + } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/domain/Feed.java b/src/main/java/konkuk/thip/feed/domain/Feed.java index 3c1533f10..7144c1bf8 100644 --- a/src/main/java/konkuk/thip/feed/domain/Feed.java +++ b/src/main/java/konkuk/thip/feed/domain/Feed.java @@ -3,7 +3,8 @@ import konkuk.thip.common.entity.BaseDomainEntity; import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.post.domain.service.PostCountService; import lombok.Builder; import lombok.Getter; import lombok.experimental.SuperBuilder; @@ -18,7 +19,7 @@ @Getter @SuperBuilder -public class Feed extends BaseDomainEntity implements CommentCountUpdatable { +public class Feed extends BaseDomainEntity implements CountUpdatable { private Long id; @@ -108,13 +109,24 @@ public static void validateImageCount(int imageSize) { } } - public void validateCreateComment(Long userId){ + // 공통된 비공개 접근 권한 검증 로직 + private void validatePrivateAccessPermission(Long userId, String action) { if (!this.isPublic && !this.creatorId.equals(userId)) { - validateCreator(userId); - throw new InvalidStateException(FEED_ACCESS_FORBIDDEN, new IllegalArgumentException("비공개 글은 작성자만 댓글을 쓸 수 있습니다.")); + throw new InvalidStateException(FEED_ACCESS_FORBIDDEN, + new IllegalArgumentException(String.format("비공개 글은 작성자만 %s 있습니다.", action))); } } + // 댓글 작성 권한 검증 + public void validateCreateComment(Long userId){ + validatePrivateAccessPermission(userId, "댓글을 쓸 수"); + } + + // 좋아요 권한 검증 + public void validateLike(Long userId){ + validatePrivateAccessPermission(userId, "좋아요 할 수"); + } + private void validateCreator(Long userId) { if (!this.creatorId.equals(userId)) { throw new InvalidStateException(FEED_ACCESS_FORBIDDEN, new IllegalArgumentException("피드 작성자만 피드를 수정할 수 있습니다.")); @@ -168,6 +180,11 @@ public void decreaseCommentCount() { commentCount--; } + @Override + public void updateLikeCount(PostCountService postCountService, boolean isLike) { + likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + } + private void checkCommentCountNotUnderflow() { if (commentCount <= 0) { throw new InvalidStateException(COMMENT_COUNT_UNDERFLOW); diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java index 8030e20f2..2b17182f4 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCommandPersistenceAdapter.java @@ -1,12 +1,58 @@ package konkuk.thip.post.adapter.out.persistence; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.common.post.PostType; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; +import konkuk.thip.post.adapter.out.mapper.PostLikeMapper; import konkuk.thip.post.application.port.out.PostLikeCommandPort; +import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.vote.adapter.out.persistence.repository.VoteJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.common.exception.code.ErrorCode.*; +import static konkuk.thip.common.exception.code.ErrorCode.RECORD_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.VOTE_NOT_FOUND; + @Repository @RequiredArgsConstructor public class PostLikeCommandPersistenceAdapter implements PostLikeCommandPort { + private final PostLikeJpaRepository postLikeJpaRepository; + private final FeedJpaRepository feedJpaRepository; + private final RecordJpaRepository recordJpaRepository; + private final VoteJpaRepository voteJpaRepository; + private final UserJpaRepository userJpaRepository; + private final PostLikeMapper postLikeMapper; + + @Override + public void save(Long userId, Long postId, PostType postType) { + + UserJpaEntity userJpaEntity = userJpaRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + PostJpaEntity postJpaEntity = findPostJpaEntity(postType, postId); + + postLikeJpaRepository.save(postLikeMapper.toJpaEntity(postJpaEntity, userJpaEntity)); + } + + @Override + public void delete(Long userId, Long postId) { + postLikeJpaRepository.deleteByUserIdAndPostId(userId, postId); + } + + private PostJpaEntity findPostJpaEntity(PostType postType, Long postId) { + return switch (postType) { + case FEED -> feedJpaRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + case RECORD -> recordJpaRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); + case VOTE -> voteJpaRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); + }; + } } diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java index e341b17a9..ce052cb6e 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java @@ -2,6 +2,7 @@ import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -14,4 +15,13 @@ public interface PostLikeJpaRepository extends JpaRepository findPostIdsLikedByUser(@Param("postIds") Set postIds, @Param("userId") Long userId); + + @Query("SELECT CASE WHEN COUNT(pl) > 0 THEN true ELSE false END " + + "FROM PostLikeJpaEntity pl " + + "WHERE pl.userJpaEntity.userId = :userId AND pl.postJpaEntity.postId = :postId") + boolean existsByUserIdAndPostId(@Param("userId") Long userId, @Param("postId") Long postId); + + @Modifying + @Query("DELETE FROM PostLikeJpaEntity pl WHERE pl.userJpaEntity.userId = :userId AND pl.postJpaEntity.postId = :postId") + void deleteByUserIdAndPostId(@Param("userId") Long userId, @Param("postId") Long postId); } diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java index 54051b3f2..cd3975869 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java @@ -16,4 +16,9 @@ public class PostLikeQueryPersistenceAdapter implements PostLikeQueryPort { public Set findPostIdsLikedByUser(Set postIds, Long userId) { return postLikeJpaRepository.findPostIdsLikedByUser(postIds, userId); } + + @Override + public boolean isLikedPostByUser(Long userId, Long postId) { + return postLikeJpaRepository.existsByUserIdAndPostId(userId, postId); + } } diff --git a/src/main/java/konkuk/thip/post/application/port/in/PostLikeUseCase.java b/src/main/java/konkuk/thip/post/application/port/in/PostLikeUseCase.java new file mode 100644 index 000000000..303eace5e --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/in/PostLikeUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.post.application.port.in; + +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; +import konkuk.thip.post.application.port.in.dto.PostIsLikeResult; + +public interface PostLikeUseCase { + PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand PostIsLikeCommand); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeCommand.java b/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeCommand.java new file mode 100644 index 000000000..100f5a290 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeCommand.java @@ -0,0 +1,16 @@ +package konkuk.thip.post.application.port.in.dto; + +import konkuk.thip.common.post.PostType; + +public record PostIsLikeCommand( + + Long userId, + + Long postId, + + PostType postType, + + boolean isLike +) +{ +} diff --git a/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeResult.java b/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeResult.java new file mode 100644 index 000000000..4e1e1c0bd --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/in/dto/PostIsLikeResult.java @@ -0,0 +1,11 @@ +package konkuk.thip.post.application.port.in.dto; + +public record PostIsLikeResult( + Long postId, + boolean isLiked +) +{ + public static PostIsLikeResult of(Long postId, boolean isLiked) { + return new PostIsLikeResult(postId, isLiked); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java index 69f57e238..1aebce592 100644 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java @@ -1,5 +1,8 @@ package konkuk.thip.post.application.port.out; -public interface PostLikeCommandPort { +import konkuk.thip.common.post.PostType; +public interface PostLikeCommandPort { + void save(Long userId, Long postId, PostType postType); + void delete(Long userId, Long postId); } diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java index 32ad51287..9c1c8a281 100644 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java @@ -5,4 +5,6 @@ public interface PostLikeQueryPort { Set findPostIdsLikedByUser(Set postIds, Long userId); + + boolean isLikedPostByUser(Long userId, Long postId); } diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java new file mode 100644 index 000000000..620c6e2d5 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -0,0 +1,55 @@ +package konkuk.thip.post.application.service; + +import jakarta.transaction.Transactional; +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.common.post.service.PostHandler; +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; +import konkuk.thip.post.application.port.in.dto.PostIsLikeResult; +import konkuk.thip.post.application.port.in.PostLikeUseCase; +import konkuk.thip.post.application.port.out.PostLikeCommandPort; +import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import konkuk.thip.post.application.service.validator.PostLikeAuthorizationValidator; +import konkuk.thip.post.domain.service.PostCountService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PostLikeService implements PostLikeUseCase { + + private final PostLikeQueryPort postLikeQueryPort; + private final PostLikeCommandPort postLikeCommandPort; + + private final PostHandler postHandler; + private final PostCountService postCountService; + private final PostLikeAuthorizationValidator postLikeAuthorizationValidator; + + @Override + @Transactional + public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { + + // 1. 게시물 타입에 맞게 검증 및 조회 + CountUpdatable post = postHandler.findPost(command.postType(), command.postId()); + // 1-1. 게시글 타입에 따른 게시물 좋아요 권한 검증 + postLikeAuthorizationValidator.validateUserCanAccessPostLike(command.postType(), post, command.userId()); + + // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회 + boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId()); + + // 3. 좋아요 상태변경 + //TODO 게시물의 좋아요 수 증가/감소 동시성 제어 로직 추가해야됨 + if (command.isLike()) { + postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 + postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); + } else { + postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 + postLikeCommandPort.delete(command.userId(), command.postId()); + } + + // 4. 게시물 좋아요 수 업데이트 + post.updateLikeCount(postCountService,command.isLike()); + postHandler.updatePost(command.postType(), post); + + return PostIsLikeResult.of(post.getId(), command.isLike()); + } +} diff --git a/src/main/java/konkuk/thip/post/application/service/policy/FeedLikeAccessPolicy.java b/src/main/java/konkuk/thip/post/application/service/policy/FeedLikeAccessPolicy.java new file mode 100644 index 000000000..146d71f8c --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/policy/FeedLikeAccessPolicy.java @@ -0,0 +1,15 @@ +package konkuk.thip.post.application.service.policy; + +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.feed.domain.Feed; +import org.springframework.stereotype.Component; + +@Component +public class FeedLikeAccessPolicy implements PostLikeAccessPolicy { + + @Override + public void validatePostLikeAccess(CountUpdatable post, Long userId) { + Feed feed = (Feed) post; + feed.validateLike(userId); + } +} diff --git a/src/main/java/konkuk/thip/post/application/service/policy/PostLikeAccessPolicy.java b/src/main/java/konkuk/thip/post/application/service/policy/PostLikeAccessPolicy.java new file mode 100644 index 000000000..486e50683 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/policy/PostLikeAccessPolicy.java @@ -0,0 +1,7 @@ +package konkuk.thip.post.application.service.policy; + +import konkuk.thip.common.post.CountUpdatable; + +public interface PostLikeAccessPolicy { + void validatePostLikeAccess(CountUpdatable post, Long userId); +} diff --git a/src/main/java/konkuk/thip/post/application/service/policy/RoomPostLikeAccessPolicy.java b/src/main/java/konkuk/thip/post/application/service/policy/RoomPostLikeAccessPolicy.java new file mode 100644 index 000000000..fa9f9f51c --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/policy/RoomPostLikeAccessPolicy.java @@ -0,0 +1,20 @@ +package konkuk.thip.post.application.service.policy; + +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.room.application.service.validator.RoomParticipantValidator; +import konkuk.thip.room.domain.RoomPost; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RoomPostLikeAccessPolicy implements PostLikeAccessPolicy { + + private final RoomParticipantValidator roomParticipantValidator; + + @Override + public void validatePostLikeAccess(CountUpdatable post, Long userId) { + RoomPost roomPost = (RoomPost) post; + roomParticipantValidator.validateUserIsRoomMember(roomPost.getRoomId(), userId); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/post/application/service/validator/PostLikeAuthorizationValidator.java b/src/main/java/konkuk/thip/post/application/service/validator/PostLikeAuthorizationValidator.java new file mode 100644 index 000000000..f3e76a4a5 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/validator/PostLikeAuthorizationValidator.java @@ -0,0 +1,43 @@ +package konkuk.thip.post.application.service.validator; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.common.post.PostType; +import konkuk.thip.post.application.service.policy.PostLikeAccessPolicy; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@Component +@RequiredArgsConstructor +public class PostLikeAuthorizationValidator { + + private final Map likeAccessPolicyMap; + + public void validateUserCanAccessPostLike(PostType type, CountUpdatable post, Long userId) { + getPolicy(type).validatePostLikeAccess(post, userId); + } + + public void validateUserCanLike(boolean alreadyLiked) { + if (alreadyLiked) { + throw new InvalidStateException(POST_ALREADY_LIKED); + } + } + + public void validateUserCanUnLike(boolean alreadyLiked) { + if (!alreadyLiked) { + throw new InvalidStateException(POST_NOT_LIKED_CANNOT_CANCEL); + } + } + + private PostLikeAccessPolicy getPolicy(PostType type) { + PostLikeAccessPolicy policy = likeAccessPolicyMap.get(type); + if (policy == null) { + throw new InvalidStateException(POST_TYPE_NOT_MATCH); + } + return policy; + } +} diff --git a/src/main/java/konkuk/thip/post/domain/service/PostCountService.java b/src/main/java/konkuk/thip/post/domain/service/PostCountService.java new file mode 100644 index 000000000..d0a367674 --- /dev/null +++ b/src/main/java/konkuk/thip/post/domain/service/PostCountService.java @@ -0,0 +1,26 @@ +package konkuk.thip.post.domain.service; + +import konkuk.thip.common.exception.InvalidStateException; +import org.springframework.stereotype.Service; + +import static konkuk.thip.common.exception.code.ErrorCode.POST_LIKE_COUNT_UNDERFLOW; + +@Service +public class PostCountService { + + public int updatePostLikeCount(boolean isLike, int likeCount) { + if (isLike) { + return ++likeCount; + + } else { + checkLikeCountNotUnderflow(likeCount); + return --likeCount; + } + } + + private void checkLikeCountNotUnderflow(int likeCount) { + if (likeCount <= 0) { + throw new InvalidStateException(POST_LIKE_COUNT_UNDERFLOW); + } + } +} diff --git a/src/main/java/konkuk/thip/record/adapter/out/jpa/RecordJpaEntity.java b/src/main/java/konkuk/thip/record/adapter/out/jpa/RecordJpaEntity.java index 48ce26a0c..33f3998c3 100644 --- a/src/main/java/konkuk/thip/record/adapter/out/jpa/RecordJpaEntity.java +++ b/src/main/java/konkuk/thip/record/adapter/out/jpa/RecordJpaEntity.java @@ -1,5 +1,6 @@ package konkuk.thip.record.adapter.out.jpa; +import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import konkuk.thip.record.domain.Record; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; @@ -43,5 +44,10 @@ public RecordJpaEntity updateFrom(Record record) { return this; } + @VisibleForTesting + public void updateLikeCount(int likeCount) { + this.likeCount = likeCount; + } + } diff --git a/src/main/java/konkuk/thip/record/domain/Record.java b/src/main/java/konkuk/thip/record/domain/Record.java index fde6543b7..77454bb56 100644 --- a/src/main/java/konkuk/thip/record/domain/Record.java +++ b/src/main/java/konkuk/thip/record/domain/Record.java @@ -2,7 +2,8 @@ import konkuk.thip.common.entity.BaseDomainEntity; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.post.domain.service.PostCountService; import konkuk.thip.room.domain.RoomPost; import lombok.Builder; import lombok.Getter; @@ -12,7 +13,7 @@ @Getter @SuperBuilder -public class Record extends BaseDomainEntity implements CommentCountUpdatable, RoomPost { +public class Record extends BaseDomainEntity implements CountUpdatable, RoomPost { private Long id; @@ -85,6 +86,11 @@ public void decreaseCommentCount() { commentCount--; } + @Override + public void updateLikeCount(PostCountService postCountService, boolean isLike) { + likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + } + private void checkCommentCountNotUnderflow() { if (commentCount <= 0) { throw new InvalidStateException(COMMENT_COUNT_UNDERFLOW); diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java index 58bb09c8f..aabec5e3a 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java @@ -7,8 +7,11 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.post.application.port.in.PostLikeUseCase; import konkuk.thip.room.adapter.in.web.request.RoomCreateRequest; import konkuk.thip.room.adapter.in.web.request.RoomJoinRequest; +import konkuk.thip.room.adapter.in.web.request.RoomPostIsLikeRequest; +import konkuk.thip.room.adapter.in.web.response.RoomPostIsLikeResponse; import konkuk.thip.room.adapter.in.web.response.RoomRecruitCloseResponse; import konkuk.thip.room.adapter.in.web.response.RoomJoinResponse; import konkuk.thip.room.adapter.in.web.response.RoomCreateResponse; @@ -31,6 +34,7 @@ public class RoomCommandController { private final RoomCreateUseCase roomCreateUseCase; private final RoomJoinUseCase roomJoinUsecase; private final RoomRecruitCloseUseCase roomRecruitCloseUsecase; + private final PostLikeUseCase postLikeUseCase; /** * 방 생성 요청 @@ -85,4 +89,17 @@ public BaseResponse closeRoomRecruit( RoomRecruitCloseResponse.of(roomRecruitCloseUsecase.closeRoomRecruit(userId, roomId)) ); } + + @Operation( + summary = "방 게시물(기록,투표) 좋아요 상태 변경", + description = "사용자가 방 게시물의 좋아요 상태를 변경합니다. (true -> 좋아요, false -> 좋아요 취소)" + ) + @ExceptionDescription(CHANGE_ROOM_LIKE_STATE) + @PostMapping("/room-posts/{postId}/likes") + public BaseResponse likeRoomPost( + @RequestBody @Valid final RoomPostIsLikeRequest request, + @Parameter(description = "좋아요 상태를 변경하려는 방 게시물 ID", example = "1")@PathVariable("postId") final Long postId, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(RoomPostIsLikeResponse.of(postLikeUseCase.changeLikeStatusPost(request.toCommand(userId, postId)))); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java index d35d26f05..e1b55a560 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java @@ -105,7 +105,7 @@ public BaseResponse getRoomMemberList( @ExceptionDescription(ROOM_PLAYING_DETAIL) @GetMapping("/rooms/{roomId}/playing") public BaseResponse getPlayingRoomDetailView( - @UserId final Long userId, + @Parameter(hidden = true) @UserId final Long userId, @PathVariable("roomId") final Long roomId ) { return BaseResponse.ok(roomShowPlayingDetailViewUseCase.getPlayingRoomDetailView(userId, roomId)); diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomPostIsLikeRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomPostIsLikeRequest.java new file mode 100644 index 000000000..efc86a872 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomPostIsLikeRequest.java @@ -0,0 +1,22 @@ +package konkuk.thip.room.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; +import konkuk.thip.room.domain.RoomPostType; + +@Schema(description = "방기록 좋아요 상태 변경 요청 DTO") +public record RoomPostIsLikeRequest( + @Schema(description = "좋아요 여부 type (true -> 좋아요, false -> 좋아요 취소)", example = "true") + @NotNull(message = "좋아요 여부는 필수입니다.") + Boolean type, + + @Schema(description = "게시물 타입 (RECORD, VOTE)", example = "RECORD") + @NotBlank(message = "게시물 타입은 필수입니다.") + String roomPostType +) { + public PostIsLikeCommand toCommand(Long userId, Long postId) { + return new PostIsLikeCommand(userId, postId, RoomPostType.from(roomPostType).toPostType(), type); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPostIsLikeResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPostIsLikeResponse.java new file mode 100644 index 000000000..da6592c90 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPostIsLikeResponse.java @@ -0,0 +1,12 @@ +package konkuk.thip.room.adapter.in.web.response; + +import konkuk.thip.post.application.port.in.dto.PostIsLikeResult; + +public record RoomPostIsLikeResponse( + Long postId, + boolean isLiked +) { + public static RoomPostIsLikeResponse of(PostIsLikeResult postIsLikeResult) { + return new RoomPostIsLikeResponse(postIsLikeResult.postId(), postIsLikeResult.isLiked()); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/domain/RoomPost.java b/src/main/java/konkuk/thip/room/domain/RoomPost.java index 59124104d..f5631e5e3 100644 --- a/src/main/java/konkuk/thip/room/domain/RoomPost.java +++ b/src/main/java/konkuk/thip/room/domain/RoomPost.java @@ -1,7 +1,7 @@ package konkuk.thip.room.domain; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; -public interface RoomPost extends CommentCountUpdatable { +public interface RoomPost extends CountUpdatable { Long getRoomId(); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/domain/RoomPostType.java b/src/main/java/konkuk/thip/room/domain/RoomPostType.java new file mode 100644 index 000000000..84d59c7f7 --- /dev/null +++ b/src/main/java/konkuk/thip/room/domain/RoomPostType.java @@ -0,0 +1,31 @@ +package konkuk.thip.room.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.post.PostType; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static konkuk.thip.common.exception.code.ErrorCode.ROOM_POST_TYPE_NOT_MATCH; + +@Getter +@RequiredArgsConstructor +public enum RoomPostType { + + RECORD("RECORD"), + VOTE("VOTE"); + + private final String type; + + public static RoomPostType from(String type) { + for (RoomPostType roomPostType : values()) { + if (roomPostType.getType().equalsIgnoreCase(type)) { + return roomPostType; + } + } + throw new InvalidStateException(ROOM_POST_TYPE_NOT_MATCH); + } + + public PostType toPostType() { + return PostType.from(this.type); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/vote/adapter/out/jpa/VoteJpaEntity.java b/src/main/java/konkuk/thip/vote/adapter/out/jpa/VoteJpaEntity.java index 2d8a28378..1a3087978 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/jpa/VoteJpaEntity.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/jpa/VoteJpaEntity.java @@ -1,5 +1,6 @@ package konkuk.thip.vote.adapter.out.jpa; +import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; @@ -42,4 +43,9 @@ public VoteJpaEntity updateFrom(Vote vote) { this.isOverview = vote.isOverview(); return this; } + + @VisibleForTesting + public void updateLikeCount(int likeCount) { + this.likeCount = likeCount; + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/vote/domain/Vote.java b/src/main/java/konkuk/thip/vote/domain/Vote.java index fd56781a0..32a988a05 100644 --- a/src/main/java/konkuk/thip/vote/domain/Vote.java +++ b/src/main/java/konkuk/thip/vote/domain/Vote.java @@ -2,7 +2,8 @@ import konkuk.thip.common.entity.BaseDomainEntity; import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.post.CommentCountUpdatable; +import konkuk.thip.common.post.CountUpdatable; +import konkuk.thip.post.domain.service.PostCountService; import konkuk.thip.room.domain.RoomPost; import lombok.Builder; import lombok.Getter; @@ -12,7 +13,7 @@ @Getter @SuperBuilder -public class Vote extends BaseDomainEntity implements CommentCountUpdatable, RoomPost { +public class Vote extends BaseDomainEntity implements CountUpdatable, RoomPost { private Long id; @@ -79,6 +80,11 @@ public void decreaseCommentCount() { commentCount--; } + @Override + public void updateLikeCount(PostCountService postCountService, boolean isLike) { + likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + } + private void checkCommentCountNotUnderflow() { if (commentCount <= 0) { throw new InvalidStateException(COMMENT_COUNT_UNDERFLOW); diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index dd9fc44fe..509dcc937 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -9,6 +9,7 @@ import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; @@ -313,4 +314,11 @@ public static VoteParticipantJpaEntity createVoteParticipant(UserJpaEntity user, .voteItemJpaEntity(item) .build(); } + + public static PostLikeJpaEntity createPostLike(UserJpaEntity user, PostJpaEntity post) { + return PostLikeJpaEntity.builder() + .userJpaEntity(user) + .postJpaEntity(post) + .build(); + } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusAPITest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusAPITest.java new file mode 100644 index 000000000..7f51c0202 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusAPITest.java @@ -0,0 +1,154 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.in.web.request.FeedIsLikeRequest; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; +import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[통합] 피드 좋아요 api 통합 테스트") +class FeedChangeLikeStatusAPITest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity book; + private FeedJpaEntity feed; + + private static final String FEED_LIKE_API_PATH = "/feeds/{feedId}/likes"; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + } + + @Test + @DisplayName("피드를 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") + void likeFeed_Success() throws Exception { + + // given + FeedIsLikeRequest request = new FeedIsLikeRequest(true); // 좋아요 상태 변경 요청(true=좋아요) + + // when + mockMvc.perform(post(FEED_LIKE_API_PATH, feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").value(feed.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(true)); + + // 좋아요 저장 여부 확인 + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); + assertThat(liked).isTrue(); + + // 좋아요 카운트 증가 확인 + FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); + assertThat(updatedFeed.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("이미 좋아요한 피드를 다시 좋아요하면 [400 에러 발생]") + void likeFeed_AlreadyLiked_Fail() throws Exception { + + // given: 미리 좋아요 저장 + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, feed)); + FeedIsLikeRequest request = new FeedIsLikeRequest(true); + + // when & then + mockMvc.perform(post(FEED_LIKE_API_PATH, feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_ALREADY_LIKED.getCode())); + } + + @Test + @DisplayName("좋아요 한 피드를 취소하면 좋아요 삭제 및 카운트 감소 [성공]") + void unlikeFeed_Success() throws Exception { + + // given: 좋아요가 저장되어 있고, likeCount도 1 반영 + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, feed)); + feed.updateLikeCount(1); // 좋아요 1개로 세팅 + feedJpaRepository.save(feed); + + FeedIsLikeRequest request = new FeedIsLikeRequest(false); + + // when + mockMvc.perform(post(FEED_LIKE_API_PATH, feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedId").value(feed.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(false)); + + // 좋아요 삭제 확인 + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); + assertThat(liked).isFalse(); + + // 좋아요 카운트 감소 확인 + FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); + assertThat(updatedFeed.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 하지 않은 피드를 좋아요 취소하면 [400 에러 발생]") + void unlikeFeed_NotLiked_Fail() throws Exception { + // given: 좋아요 없음 + FeedIsLikeRequest request = new FeedIsLikeRequest(false); + + // when & then + mockMvc.perform(post(FEED_LIKE_API_PATH, feed.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_NOT_LIKED_CANNOT_CANCEL.getCode())); + } + +} diff --git a/src/test/java/konkuk/thip/feed/domain/FeedTest.java b/src/test/java/konkuk/thip/feed/domain/FeedTest.java index a4b3d39f1..303ea835c 100644 --- a/src/test/java/konkuk/thip/feed/domain/FeedTest.java +++ b/src/test/java/konkuk/thip/feed/domain/FeedTest.java @@ -2,6 +2,8 @@ import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.post.domain.service.PostCountService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +18,13 @@ @DisplayName("[단위] Feed 도메인 테스트") class FeedTest { + private PostCountService postCountService; + + @BeforeEach + void setUp() { + postCountService = new PostCountService(); + } + private final Long CREATOR_ID = 1L; private final Long OTHER_USER_ID = 2L; @@ -110,7 +119,7 @@ void validateCreateComment_nonCreatorOnPrivateFeed_throws() { () -> feed.validateCreateComment(OTHER_USER_ID)); assertEquals(FEED_ACCESS_FORBIDDEN, ex.getErrorCode()); - assertFalse(ex.getCause().getMessage().contains("비공개 글은 작성자만")); + assertTrue(ex.getCause().getMessage().contains("비공개 글은 작성자만")); } @Test @@ -267,4 +276,75 @@ private Feed makeFeedWithPublicStatus(Boolean isPublic) { .contentList(Collections.emptyList()) .build(); } + + @Test + @DisplayName("updateLikeCount: like == true 면 likeCount 가 1씩 증가한다.") + void updateLikeCount_likeTrue_increments() { + Feed feed = createPublicFeed(); + + feed.updateLikeCount(postCountService, true); + assertEquals(1, feed.getLikeCount()); + + feed.updateLikeCount(postCountService, true); + assertEquals(2, feed.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 1씩 감소한다.") + void updateLikeCount_likeFalse_decrements() { + Feed feed = createPublicFeed(); + // 먼저 likeCount 증가 셋업 + feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true); + assertEquals(2, feed.getLikeCount()); + + feed.updateLikeCount(postCountService, false); + assertEquals(1, feed.getLikeCount()); + + feed.updateLikeCount(postCountService, false); + assertEquals(0, feed.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 0 이하로 내려가면 InvalidStateException이 발생한다.") + void updateLikeCount_likeFalse_underflow_throws() { + Feed feed = createPublicFeed(); + assertEquals(0, feed.getLikeCount()); + + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { + feed.updateLikeCount(postCountService, false); + }); + + assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); + } + + @Test + @DisplayName("validateLike: 공개 피드는 누구나 좋아요 할 수 있다") + void validateLike_publicFeed_anyUser_passes() { + Feed feed = createPublicFeed(); + + assertDoesNotThrow(() -> feed.validateLike(OTHER_USER_ID)); + assertDoesNotThrow(() -> feed.validateLike(CREATOR_ID)); + } + + @Test + @DisplayName("validateLike: 비공개 피드이고 작성자가 아닌 경우 좋아요 시도하면 InvalidStateException이 발생한다.") + void validateLike_privateFeed_nonCreator_throws() { + Feed feed = createPrivateFeed(); + + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> feed.validateLike(OTHER_USER_ID)); + + assertEquals(FEED_ACCESS_FORBIDDEN, ex.getErrorCode()); + assertTrue(ex.getCause().getMessage().contains("비공개 글은 작성자만 좋아요 할 수 있습니다.")); + } + + @Test + @DisplayName("validateLike: 비공개 피드이고 작성자인 경우 좋아요 할 수 있다") + void validateLike_privateFeed_creator_passes() { + Feed feed = createPrivateFeed(); + + assertDoesNotThrow(() -> feed.validateLike(CREATOR_ID)); + } + } diff --git a/src/test/java/konkuk/thip/record/domain/RecordTest.java b/src/test/java/konkuk/thip/record/domain/RecordTest.java index 1a5485c4a..4dba6a5b0 100644 --- a/src/test/java/konkuk/thip/record/domain/RecordTest.java +++ b/src/test/java/konkuk/thip/record/domain/RecordTest.java @@ -1,15 +1,25 @@ package konkuk.thip.record.domain; import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.post.domain.service.PostCountService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static konkuk.thip.common.exception.code.ErrorCode.COMMENT_COUNT_UNDERFLOW; +import static konkuk.thip.common.exception.code.ErrorCode.POST_LIKE_COUNT_UNDERFLOW; import static org.junit.jupiter.api.Assertions.*; @DisplayName("[단위] Record 도메인 테스트") class RecordTest { + private PostCountService postCountService; + + @BeforeEach + void setUp() { + postCountService = new PostCountService(); + } + private final Long CREATOR_ID = 1L; private Record createWithCommentRecord() { @@ -124,5 +134,46 @@ void decreaseCommentCount_belowZero_throws() { assertEquals(COMMENT_COUNT_UNDERFLOW, ex.getErrorCode()); } + @Test + @DisplayName("updateLikeCount: like == true 면 likeCount 가 1씩 증가한다.") + void updateLikeCount_likeTrue_increments() { + Record record = createWithCommentRecord(); + + record.updateLikeCount(postCountService,true); + assertEquals(1, record.getLikeCount()); + + record.updateLikeCount(postCountService,true); + assertEquals(2, record.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 1씩 감소한다.") + void updateLikeCount_likeFalse_decrements() { + Record record = createWithCommentRecord(); + + // 먼저 likeCount 증가 셋업 + record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true); + assertEquals(2, record.getLikeCount()); + + record.updateLikeCount(postCountService,false); + assertEquals(1, record.getLikeCount()); + + record.updateLikeCount(postCountService,false); + assertEquals(0, record.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 0 이하로 내려가면 InvalidStateException이 발생한다.") + void updateLikeCount_likeFalse_underflow_throws() { + Record record = createWithCommentRecord(); + assertEquals(0, record.getLikeCount()); + + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { + record.updateLikeCount(postCountService,false); + }); + + assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); + } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusAPITest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusAPITest.java new file mode 100644 index 000000000..c9d920af3 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusAPITest.java @@ -0,0 +1,254 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; +import konkuk.thip.room.adapter.in.web.request.RoomPostIsLikeRequest; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.adapter.out.persistence.repository.VoteJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; +import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[통합] 방 게시물(기록,투표) 좋아요 api 통합 테스트") +class RoomPostChangeLikeStatusAPITest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + @Autowired private VoteJpaRepository voteJpaRepository; + + + private UserJpaEntity user; + private BookJpaEntity book; + private FeedJpaEntity feed; + private CategoryJpaEntity category; + private RoomJpaEntity room; + private RecordJpaEntity record; + private VoteJpaEntity vote; + + private static final String ROOM_POST_LIKE_API_PATH = "/room-posts/{postId}/likes"; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + // 1번방에 유저 1이 호스트 + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room,user, RoomParticipantRole.HOST, 80.0)); + record = recordJpaRepository.save(TestEntityFactory.createRecord(user,room)); + vote = voteJpaRepository.save(TestEntityFactory.createVote(user,room)); + } + + @Test + @DisplayName("기록 게시물을 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") + void likeRecordPost_Success() throws Exception { + // given + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "RECORD"); + + //when + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postId").value(record.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(true)); + + //then + // 좋아요 저장 확인 + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); + assertThat(liked).isTrue(); + + // 좋아요 카운트 증가 확인 + RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(updatedRecord.getLikeCount()).isEqualTo(1); + } + + + @Test + @DisplayName("이미 좋아요한 기록 게시물을 다시 좋아요하면 [400 에러 발생]") + void likeRecordPost_AlreadyLiked_Fail() throws Exception { + //given + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, record)); + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "RECORD"); + + //when & then + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_ALREADY_LIKED.getCode())); + } + + @Test + @DisplayName("좋아요한 기록 게시물 좋아요 취소하면 좋아요 삭제 및 카운트 감소 [성공]") + void unlikeRecordPost_Success() throws Exception { + //given + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, record)); + record.updateLikeCount(1); + recordJpaRepository.save(record); + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); + + //when + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postId").value(record.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(false)); + + //then + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); + assertThat(liked).isFalse(); + + RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(updatedRecord.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 하지 않은 기록 게시물을 좋아요 취소하면 [400 에러 발생]") + void unlikeRecordPost_NotLiked_Fail() throws Exception { + //given + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); + + //when & then + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_NOT_LIKED_CANNOT_CANCEL.getCode())); + } + + // --- Vote 게시물에 대해서도 동일 패턴 테스트 --- + + @Test + @DisplayName("투표 게시물을 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") + void likeVotePost_Success() throws Exception { + //given + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "VOTE"); + + //when + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, vote.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postId").value(vote.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(true)); + + //then + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); + assertThat(liked).isTrue(); + + VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(updatedVote.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("이미 좋아요한 투표 게시물을 다시 좋아요하면 [400 에러 발생]") + void likeVotePost_AlreadyLiked_Fail() throws Exception { + //given + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, vote)); + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "VOTE"); + + //when & then + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, vote.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_ALREADY_LIKED.getCode())); + } + + @Test + @DisplayName("좋아요한 투표 게시물 좋아요 취소하면 좋아요 삭제 및 카운트 감소 [성공]") + void unlikeVotePost_Success() throws Exception { + //given + postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, vote)); + vote.updateLikeCount(1); + voteJpaRepository.save(vote); + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); + + //when + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, vote.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postId").value(vote.getPostId())) + .andExpect(jsonPath("$.data.isLiked").value(false)); + + boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); + assertThat(liked).isFalse(); + + VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(updatedVote.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 하지 않은 투표 게시물을 좋아요 취소하면 [400 에러 발생]") + void unlikeVotePost_NotLiked_Fail() throws Exception { + //given + RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); + + //when & then + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, vote.getPostId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(POST_NOT_LIKED_CANNOT_CANCEL.getCode())); + } +} diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java new file mode 100644 index 000000000..c336e4a31 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java @@ -0,0 +1,122 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.*; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[단위] 방 게시물(기록,투표) 좋아요 api controller 단위 테스트") +class RoomPostChangeLikeStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + + private UserJpaEntity user1; + private UserJpaEntity user2; + private BookJpaEntity book; + private CategoryJpaEntity category; + private RoomJpaEntity room; + private RecordJpaEntity record; + + private static final String ROOM_POST_LIKE_API_PATH = "/room-posts/{postId}/likes"; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user1 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + user2 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + // 1번방에 유저 1이 호스트 + roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room,user1, RoomParticipantRole.HOST, 80.0)); + record = recordJpaRepository.save(TestEntityFactory.createRecord(user1,room)); + } + + private Map buildValidLikeRequest(Boolean isLike, String postType) { + Map request = new HashMap<>(); + request.put("type", isLike); + request.put("roomPostType", postType); + return request; + } + + private void assertBadRequest(int expectedCode, Map request, String message) throws Exception { + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user1.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(request)) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(expectedCode)) + .andExpect(jsonPath("$.message", containsString(message))); + } + + @Test + @DisplayName("잘못된 RoomPostType 값이 들어오면 400 Bad Request 반환") + void invalidPostType_shouldReturnBadRequest() throws Exception { + Map req = buildValidLikeRequest(true,"FEED"); + assertBadRequest(ROOM_POST_TYPE_NOT_MATCH.getCode(), req, "일치하는 방 게시물 타입 이름이 없습니다."); + } + + @Test + @DisplayName("방 참여자가 아닌 사용자가 방 게시물에 좋아요 하려고 하면 400 Bad Request 반환") + void nonParticipantUser_likeRoomPost_shouldReturnBadRequest() throws Exception { + Map req = buildValidLikeRequest(true, "RECORD"); + + mockMvc.perform(post(ROOM_POST_LIKE_API_PATH, record.getPostId()) + .requestAttr("userId", user2.getUserId()) // 방 참여하지 않은 user2로 요청 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(req)) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ROOM_ACCESS_FORBIDDEN.getCode())) + .andExpect(jsonPath("$.message", containsString("사용자가 이 방의 참가자가 아닙니다."))); + } + + +} diff --git a/src/test/java/konkuk/thip/vote/domain/VoteTest.java b/src/test/java/konkuk/thip/vote/domain/VoteTest.java index 95b871931..1ea2b3729 100644 --- a/src/test/java/konkuk/thip/vote/domain/VoteTest.java +++ b/src/test/java/konkuk/thip/vote/domain/VoteTest.java @@ -1,15 +1,25 @@ package konkuk.thip.vote.domain; import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.post.domain.service.PostCountService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static konkuk.thip.common.exception.code.ErrorCode.COMMENT_COUNT_UNDERFLOW; +import static konkuk.thip.common.exception.code.ErrorCode.POST_LIKE_COUNT_UNDERFLOW; import static org.junit.jupiter.api.Assertions.*; @DisplayName("[단위] Vote 도메인 테스트") class VoteTest { + private PostCountService postCountService; + + @BeforeEach + void setUp() { + postCountService = new PostCountService(); + } + private final Long CREATOR_ID = 1L; private Vote createWithCommentVote() { @@ -124,4 +134,46 @@ void decreaseCommentCount_belowZero_throws() { assertEquals(COMMENT_COUNT_UNDERFLOW, ex.getErrorCode()); } + @Test + @DisplayName("updateLikeCount: like == true 면 likeCount 가 1씩 증가한다.") + void updateLikeCount_likeTrue_increments() { + Vote vote = createWithCommentVote(); + + vote.updateLikeCount(postCountService,true); + assertEquals(1, vote.getLikeCount()); + + vote.updateLikeCount(postCountService,true); + assertEquals(2, vote.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 1씩 감소한다.") + void updateLikeCount_likeFalse_decrements() { + Vote vote = createWithCommentVote(); + + // 먼저 likeCount 증가 셋업 + vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true); + assertEquals(2, vote.getLikeCount()); + + vote.updateLikeCount(postCountService,false); + assertEquals(1, vote.getLikeCount()); + + vote.updateLikeCount(postCountService,false); + assertEquals(0, vote.getLikeCount()); + } + + @Test + @DisplayName("updateLikeCount: like == false 면 likeCount 가 0 이하로 내려가면 InvalidStateException이 발생한다.") + void updateLikeCount_likeFalse_underflow_throws() { + Vote vote = createWithCommentVote(); + assertEquals(0, vote.getLikeCount()); + + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { + vote.updateLikeCount(postCountService,false); + }); + + assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); + } + }