diff --git a/src/main/java/today/seasoning/seasoning/article/controller/ArticleController.java b/src/main/java/today/seasoning/seasoning/article/controller/ArticleController.java index eeccd5a..86416b3 100644 --- a/src/main/java/today/seasoning/seasoning/article/controller/ArticleController.java +++ b/src/main/java/today/seasoning/seasoning/article/controller/ArticleController.java @@ -24,13 +24,14 @@ import today.seasoning.seasoning.article.dto.FindMyArticlesByYearResponse; import today.seasoning.seasoning.article.dto.RegisterArticleRequest; import today.seasoning.seasoning.article.dto.UpdateArticleRequest; -import today.seasoning.seasoning.article.service.ArticleLikeService; +import today.seasoning.seasoning.article.service.CancelArticleLikeService; import today.seasoning.seasoning.article.service.DeleteArticleService; import today.seasoning.seasoning.article.service.FindArticleService; import today.seasoning.seasoning.article.service.FindCollageService; import today.seasoning.seasoning.article.service.FindFriendArticlesService; import today.seasoning.seasoning.article.service.FindMyArticlesByTermService; import today.seasoning.seasoning.article.service.FindMyArticlesByYearService; +import today.seasoning.seasoning.article.service.RegisterArticleLikeService; import today.seasoning.seasoning.article.service.RegisterArticleService; import today.seasoning.seasoning.article.service.UpdateArticleService; import today.seasoning.seasoning.common.UserPrincipal; @@ -47,7 +48,8 @@ public class ArticleController { private final DeleteArticleService deleteArticleService; private final FindMyArticlesByYearService findMyArticlesByYearService; private final FindMyArticlesByTermService findMyArticlesByTermService; - private final ArticleLikeService articleLikeService; + private final RegisterArticleLikeService registerArticleLikeService; + private final CancelArticleLikeService cancelArticleLikeService; private final FindCollageService findCollageService; private final FindFriendArticlesService findFriendArticlesService; @@ -115,7 +117,7 @@ public ResponseEntity likeArticle( @AuthenticationPrincipal UserPrincipal principal, @PathVariable String articleId ) { - articleLikeService.doLike(principal.getId(), TsidUtil.toLong(articleId)); + registerArticleLikeService.doService(principal.getId(), TsidUtil.toLong(articleId)); return ResponseEntity.ok().build(); } @@ -124,7 +126,7 @@ public ResponseEntity cancelLikeArticle( @AuthenticationPrincipal UserPrincipal principal, @PathVariable String articleId ) { - articleLikeService.cancelLike(principal.getId(), TsidUtil.toLong(articleId)); + cancelArticleLikeService.doService(principal.getId(), TsidUtil.toLong(articleId)); return ResponseEntity.ok().build(); } diff --git a/src/main/java/today/seasoning/seasoning/article/service/ArticleLikeService.java b/src/main/java/today/seasoning/seasoning/article/service/ArticleLikeService.java deleted file mode 100644 index 5d6a626..0000000 --- a/src/main/java/today/seasoning/seasoning/article/service/ArticleLikeService.java +++ /dev/null @@ -1,67 +0,0 @@ -package today.seasoning.seasoning.article.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import today.seasoning.seasoning.article.domain.Article; -import today.seasoning.seasoning.article.domain.ArticleLike; -import today.seasoning.seasoning.article.domain.ArticleLikeRepository; -import today.seasoning.seasoning.article.domain.ArticleRepository; -import today.seasoning.seasoning.article.event.ArticleLikedEvent; -import today.seasoning.seasoning.common.exception.CustomException; -import today.seasoning.seasoning.friendship.service.CheckFriendshipService; -import today.seasoning.seasoning.user.domain.User; -import today.seasoning.seasoning.user.domain.UserRepository; - -@Service -@Transactional -@RequiredArgsConstructor -public class ArticleLikeService { - - private final UserRepository userRepository; - private final ArticleRepository articleRepository; - private final ArticleLikeRepository articleLikeRepository; - private final CheckFriendshipService checkFriendshipService; - private final ApplicationEventPublisher applicationEventPublisher; - - public void doLike(Long userId, Long articleId) { - Article article = articleRepository.findByIdOrElseThrow(articleId); - User user = userRepository.findByIdOrElseThrow(userId); - - validatePermission(userId, article); - - if (articleLikeRepository.findByArticleAndUser(articleId, userId).isEmpty()) { - User author = article.getUser(); - articleLikeRepository.save(new ArticleLike(article, user)); - - if (user != author) { - ArticleLikedEvent articleLikedEvent = new ArticleLikedEvent(user.getId(), author.getId(), articleId); - applicationEventPublisher.publishEvent(articleLikedEvent); - } - } - } - - public void cancelLike(Long userId, Long articleId) { - Article article = articleRepository.findByIdOrElseThrow(articleId); - validatePermission(userId, article); - articleLikeRepository.findByArticleAndUser(articleId, userId) - .ifPresent(articleLike -> articleLikeRepository.deleteById(articleLike.getId())); - } - - private void validatePermission(Long userId, Article article) { - Long authorId = article.getUser().getId(); - - // 자신의 글 - if (authorId.equals(userId)) { - return; - } - // 공개된 친구의 글 - if (article.isPublished() && checkFriendshipService.doCheck(userId, authorId)) { - return; - } - - throw new CustomException(HttpStatus.FORBIDDEN, "접근 권한 없음"); - } -} diff --git a/src/main/java/today/seasoning/seasoning/article/service/CancelArticleLikeService.java b/src/main/java/today/seasoning/seasoning/article/service/CancelArticleLikeService.java new file mode 100644 index 0000000..e12cb33 --- /dev/null +++ b/src/main/java/today/seasoning/seasoning/article/service/CancelArticleLikeService.java @@ -0,0 +1,24 @@ +package today.seasoning.seasoning.article.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import today.seasoning.seasoning.article.domain.ArticleLikeRepository; +import today.seasoning.seasoning.common.exception.CustomException; + +@Service +@RequiredArgsConstructor +public class CancelArticleLikeService { + + private final ArticleLikeRepository articleLikeRepository; + private final ValidateArticleLikePolicy validateArticleLikePolicy; + + @Transactional + public void doService(Long userId, Long articleId) { + if (!validateArticleLikePolicy.validate(userId, articleId)) { + throw new CustomException(HttpStatus.FORBIDDEN, "권한 없음"); + } + articleLikeRepository.findByArticleAndUser(articleId, userId).ifPresent(articleLikeRepository::delete); + } +} diff --git a/src/main/java/today/seasoning/seasoning/article/service/RegisterArticleLikeService.java b/src/main/java/today/seasoning/seasoning/article/service/RegisterArticleLikeService.java new file mode 100644 index 0000000..701fc49 --- /dev/null +++ b/src/main/java/today/seasoning/seasoning/article/service/RegisterArticleLikeService.java @@ -0,0 +1,52 @@ +package today.seasoning.seasoning.article.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import today.seasoning.seasoning.article.domain.Article; +import today.seasoning.seasoning.article.domain.ArticleLike; +import today.seasoning.seasoning.article.domain.ArticleLikeRepository; +import today.seasoning.seasoning.article.domain.ArticleRepository; +import today.seasoning.seasoning.article.event.ArticleLikedEvent; +import today.seasoning.seasoning.common.exception.CustomException; +import today.seasoning.seasoning.user.domain.User; +import today.seasoning.seasoning.user.domain.UserRepository; + +@Service +@RequiredArgsConstructor +public class RegisterArticleLikeService { + + private final UserRepository userRepository; + private final ArticleRepository articleRepository; + private final ArticleLikeRepository articleLikeRepository; + private final ValidateArticleLikePolicy validateArticleLikePolicy; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void doService(Long userId, Long articleId) { + Article article = articleRepository.findByIdOrElseThrow(articleId); + User user = userRepository.findByIdOrElseThrow(userId); + User author = article.getUser(); + + // 사용자 권한 검증 + if (!validateArticleLikePolicy.validate(userId, articleId)) { + throw new CustomException(HttpStatus.FORBIDDEN, "권한 없음"); + } + + // 중복 요청의 경우 무시 + if (articleLikeRepository.findByArticleAndUser(articleId, userId).isPresent()) { + return; + } + + articleLikeRepository.save(new ArticleLike(article, user)); + + // 타인의 글에 좋아요를 누른 경우, 상대방에게 관련 알림 전송 + if (user != author) { + ArticleLikedEvent articleLikedEvent = new ArticleLikedEvent(user.getId(), author.getId(), articleId); + applicationEventPublisher.publishEvent(articleLikedEvent); + } + } + +} diff --git a/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicy.java b/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicy.java new file mode 100644 index 0000000..733503e --- /dev/null +++ b/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicy.java @@ -0,0 +1,6 @@ +package today.seasoning.seasoning.article.service; + +public interface ValidateArticleLikePolicy { + + boolean validate(Long userId, Long articleId); +} diff --git a/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicyImpl.java b/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicyImpl.java new file mode 100644 index 0000000..a9821dd --- /dev/null +++ b/src/main/java/today/seasoning/seasoning/article/service/ValidateArticleLikePolicyImpl.java @@ -0,0 +1,32 @@ +package today.seasoning.seasoning.article.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import today.seasoning.seasoning.article.domain.Article; +import today.seasoning.seasoning.article.domain.ArticleRepository; +import today.seasoning.seasoning.friendship.domain.FriendshipRepository; + +@Component +@RequiredArgsConstructor +public class ValidateArticleLikePolicyImpl implements ValidateArticleLikePolicy { + + private final ArticleRepository articleRepository; + private final FriendshipRepository friendshipRepository; + + @Override + public boolean validate(Long userId, Long articleId) { + Article article = articleRepository.findByIdOrElseThrow(articleId); + Long authorId = article.getUser().getId(); + + // 자신의 글 + if (authorId.equals(userId)) { + return true; + } + // 공개된 친구의 글 + if (article.isPublished() && friendshipRepository.existsByUserIdAndFriendId(userId, authorId)) { + return true; + } + + return false; + } +} diff --git a/src/test/java/today/seasoning/seasoning/article/integration/CancelArticleLikeIntegrationTest.java b/src/test/java/today/seasoning/seasoning/article/integration/CancelArticleLikeIntegrationTest.java new file mode 100644 index 0000000..35ab523 --- /dev/null +++ b/src/test/java/today/seasoning/seasoning/article/integration/CancelArticleLikeIntegrationTest.java @@ -0,0 +1,170 @@ +package today.seasoning.seasoning.article.integration; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import today.seasoning.seasoning.BaseIntegrationTest; +import today.seasoning.seasoning.article.domain.Article; +import today.seasoning.seasoning.article.domain.ArticleLike; +import today.seasoning.seasoning.article.domain.ArticleLikeRepository; +import today.seasoning.seasoning.article.domain.ArticleRepository; +import today.seasoning.seasoning.common.enums.LoginType; +import today.seasoning.seasoning.common.util.TsidUtil; +import today.seasoning.seasoning.friendship.domain.Friendship; +import today.seasoning.seasoning.friendship.domain.FriendshipRepository; +import today.seasoning.seasoning.notification.domain.UserNotificationRepository; +import today.seasoning.seasoning.user.domain.User; +import today.seasoning.seasoning.user.domain.UserRepository; + +@DisplayName("기록장 좋아요 취소 통합 테스트") +public class CancelArticleLikeIntegrationTest extends BaseIntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + ArticleRepository articleRepository; + + @Autowired + ArticleLikeRepository articleLikeRepository; + + @Autowired + FriendshipRepository friendshipRepository; + + @Autowired + UserNotificationRepository userNotificationRepository; + + @InjectSoftAssertions + SoftAssertions softAssertions; + + private void makeFriendships(User user, User friend) { + friendshipRepository.save(new Friendship(user, friend)); + friendshipRepository.save(new Friendship(friend, user)); + } + + private void cancelFriendships(User user, User friend) { + friendshipRepository.findByUserIdAndFriendId(user.getId(), friend.getId()).ifPresent(friendshipRepository::delete); + friendshipRepository.findByUserIdAndFriendId(friend.getId(), user.getId()).ifPresent(friendshipRepository::delete); + } + + @Test + @DisplayName("성공 - 자신의 공개 기록장") + void test() { + //given + User user = userRepository.save(new User("nickname0", "https://test.org/user0.jpg", "user0@email.com", LoginType.KAKAO)); + Article article = articleRepository.save(new Article(user, true, 2024, 1, "contents")); + ArticleLike articleLike = articleLikeRepository.save(new ArticleLike(article, user)); + + //when : 자신의 공개 기록장에 좋아요 취소 요청 시 + String url = "/article/" + TsidUtil.toString(article.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 좋아요는 성공적으로 삭제되어야 한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(200); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(0); + softAssertions.assertThat(articleLikeRepository.findById(articleLike.getId())).isEmpty(); + } + + @Test + @DisplayName("성공 - 자신의 비공개 기록장") + void test2() { + //given + User user = userRepository.save(new User("nickname0", "https://test.org/user0.jpg", "user0@email.com", LoginType.KAKAO)); + Article article = articleRepository.save(new Article(user, true, 2024, 1, "contents")); + ArticleLike articleLike = articleLikeRepository.save(new ArticleLike(article, user)); + + //when : 자신의 비공개 기록장에 좋아요 취소 요청 시 + String url = "/article/" + TsidUtil.toString(article.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 좋아요는 성공적으로 삭제되어야 한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(200); + softAssertions.assertThat(articleLikeRepository.findById(articleLike.getId())).isEmpty(); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("성공 - 친구의 공개 기록장") + void test3() { + //given + User user = userRepository.save(new User("nickname", "https://test.org/user0.jpg", "user1@email.com", LoginType.KAKAO)); + User friend = userRepository.save(new User("friend", "https://test.org/user1.jpg", "user2@email.com", LoginType.KAKAO)); + Article friendArticle = articleRepository.save(new Article(friend, true, 2024, 1, "contents")); + makeFriendships(user, friend); + ArticleLike articleLike = articleLikeRepository.save(new ArticleLike(friendArticle, user)); + + //when : 친구의 공개 기록장에 좋아요 취소 요청 시 + String url = "/article/" + TsidUtil.toString(friendArticle.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 좋아요는 성공적으로 삭제되어야 한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(200); + softAssertions.assertThat(articleLikeRepository.findById(articleLike.getId())).isEmpty(); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(0); + } + + @Test + @DisplayName("실패 - 친구의 비공개 기록장") + void test4() { + //given + User user = userRepository.save(new User("nickname1", "https://test.org/user0.jpg", "user1@email.com", LoginType.KAKAO)); + User friend = userRepository.save(new User("friend", "https://test.org/user1.jpg", "user2@email.com", LoginType.KAKAO)); + Article friendArticle = articleRepository.save(new Article(friend, false, 2024, 1, "contents")); + makeFriendships(user, friend); + ArticleLike articleLike = articleLikeRepository.save(new ArticleLike(friendArticle, user)); + + //when : 친구의 비공개 기록장에 좋아요 취소 요청 시 + String url = "/article/" + TsidUtil.toString(friendArticle.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 요청은 거절되어야 한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(403); + softAssertions.assertThat(articleLikeRepository.findById(articleLike.getId())).isPresent(); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("실패 - 타인의 기록장") + void test5() { + //given : 상대방의 기록장에 좋아요를 눌렀었지만 현재는 친구가 아닌 경우 + User user = userRepository.save(new User("nickname1", "https://test.org/user0.jpg", "user1@email.com", LoginType.KAKAO)); + User stranger = userRepository.save( + new User("nickname2", "https://test.org/user1.jpg", "user2@email.com", LoginType.KAKAO)); + Article strangerArticle = articleRepository.save(new Article(stranger, true, 2024, 1, "contents")); + makeFriendships(user, stranger); + ArticleLike articleLike = articleLikeRepository.save(new ArticleLike(strangerArticle, user)); + cancelFriendships(user, stranger); + + //when : 해당 좋아요에 대한 취소 요청 시 + String url = "/article/" + TsidUtil.toString(strangerArticle.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 요청은 거절되어야 한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(403); + softAssertions.assertThat(articleLikeRepository.findById(articleLike.getId())).isPresent(); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("성공 - 친구의 취소 중복 요청") + void test6() { + //given : 회원이 친구의 공개 기록장에 좋아요를 누르지 않은 상태에서 + User user = userRepository.save(new User("nickname1", "https://test.org/user0.jpg", "user1@email.com", LoginType.KAKAO)); + User friend = userRepository.save(new User("friend", "https://test.org/user1.jpg", "user2@email.com", LoginType.KAKAO)); + Article friendArticle = articleRepository.save(new Article(friend, true, 2024, 1, "contents")); + makeFriendships(user, friend); + + //when : 친구의 공개 기록장에 좋아요 취소 요청 시 + String url = "/article/" + TsidUtil.toString(friendArticle.getId()) + "/like"; + ExtractableResponse response = delete(url, user.getId(), null); + + //then : 요청은 성공한다 + softAssertions.assertThat(response.statusCode()).isEqualTo(200); + softAssertions.assertThat(articleLikeRepository.count()).isEqualTo(0); + } + +}