diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 0ea4b29a5..75fbf5b0f 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -100,20 +100,20 @@ public enum ErrorCode { // news INVALID_NEWS_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 소식지만 제어할 수 있습니다."), + ALREADY_LIKED_NEWS(HttpStatus.BAD_REQUEST.value(), "이미 좋아요한 소식지입니다."), + NOT_LIKED_NEWS(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 소식지입니다."), // mentor CHANNEL_SEQUENCE_NOT_UNIQUE(HttpStatus.BAD_REQUEST.value(), "채널의 순서가 중복되었습니다."), CHANNEL_REGISTRATION_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST.value(), "등록 가능한 채널 수를 초과하였습니다."), - - // database - DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), - - // mentor ALREADY_MENTOR(HttpStatus.BAD_REQUEST.value(), "이미 멘토로 등록된 사용자입니다."), MENTORING_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 멘토링 신청을 찾을 수 없습니다."), UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."), MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."), + // database + DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), + // general JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/news/controller/NewsController.java b/src/main/java/com/example/solidconnection/news/controller/NewsController.java index 51b739f8c..dd522fb47 100644 --- a/src/main/java/com/example/solidconnection/news/controller/NewsController.java +++ b/src/main/java/com/example/solidconnection/news/controller/NewsController.java @@ -1,11 +1,13 @@ package com.example.solidconnection.news.controller; import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.news.dto.LikedNewsResponse; import com.example.solidconnection.news.dto.NewsCommandResponse; import com.example.solidconnection.news.dto.NewsCreateRequest; import com.example.solidconnection.news.dto.NewsListResponse; import com.example.solidconnection.news.dto.NewsUpdateRequest; import com.example.solidconnection.news.service.NewsCommandService; +import com.example.solidconnection.news.service.NewsLikeService; import com.example.solidconnection.news.service.NewsQueryService; import com.example.solidconnection.security.annotation.RequireRoleAccess; import com.example.solidconnection.siteuser.domain.Role; @@ -31,6 +33,7 @@ public class NewsController { private final NewsQueryService newsQueryService; private final NewsCommandService newsCommandService; + private final NewsLikeService newsLikeService; // todo: 추후 Slice 적용 @GetMapping @@ -77,4 +80,31 @@ public ResponseEntity deleteNewsById( NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(siteUser, newsId); return ResponseEntity.ok(newsCommandResponse); } + + @GetMapping("/{news-id}/like") + public ResponseEntity isNewsLiked( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news-id") Long newsId + ) { + LikedNewsResponse likedNewsResponse = newsLikeService.isNewsLiked(siteUser.getId(), newsId); + return ResponseEntity.ok(likedNewsResponse); + } + + @PostMapping("/{news-id}/like") + public ResponseEntity addNewsLike( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news-id") Long newsId + ) { + newsLikeService.addNewsLike(siteUser.getId(), newsId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{news-id}/like") + public ResponseEntity cancelNewsLike( + @AuthorizedUser SiteUser siteUser, + @PathVariable("news-id") Long newsId + ) { + newsLikeService.cancelNewsLike(siteUser.getId(), newsId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/news/domain/LikedNews.java b/src/main/java/com/example/solidconnection/news/domain/LikedNews.java index a50e31659..9b7affad7 100644 --- a/src/main/java/com/example/solidconnection/news/domain/LikedNews.java +++ b/src/main/java/com/example/solidconnection/news/domain/LikedNews.java @@ -33,4 +33,9 @@ public class LikedNews { @Column(name = "site_user_id") private long siteUserId; + + public LikedNews(long newsId, long siteUserId) { + this.newsId = newsId; + this.siteUserId = siteUserId; + } } diff --git a/src/main/java/com/example/solidconnection/news/dto/LikedNewsResponse.java b/src/main/java/com/example/solidconnection/news/dto/LikedNewsResponse.java new file mode 100644 index 000000000..b854b9bf0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/dto/LikedNewsResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.news.dto; + +public record LikedNewsResponse( + boolean isLike +) { + + public static LikedNewsResponse of(boolean isLike) { + return new LikedNewsResponse(isLike); + } +} diff --git a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java new file mode 100644 index 000000000..6c9a51585 --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.news.repository; + +import com.example.solidconnection.news.domain.LikedNews; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikedNewsRepository extends JpaRepository { + + boolean existsByNewsIdAndSiteUserId(long newsId, long siteUserId); + + Optional findByNewsIdAndSiteUserId(long newsId, long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java b/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java new file mode 100644 index 000000000..0ca88992f --- /dev/null +++ b/src/main/java/com/example/solidconnection/news/service/NewsLikeService.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.LikedNews; +import com.example.solidconnection.news.dto.LikedNewsResponse; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import com.example.solidconnection.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_NEWS; +import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_NEWS; + +@RequiredArgsConstructor +@Service +public class NewsLikeService { + + private final NewsRepository newsRepository; + private final LikedNewsRepository likedNewsRepository; + + @Transactional(readOnly = true) + public LikedNewsResponse isNewsLiked(long siteUserId, long newsId) { + if (!newsRepository.existsById(newsId)) { + throw new CustomException(NEWS_NOT_FOUND); + } + boolean isLike = likedNewsRepository.existsByNewsIdAndSiteUserId(newsId, siteUserId); + return LikedNewsResponse.of(isLike); + } + + @Transactional + public void addNewsLike(long siteUserId, long newsId) { + if (!newsRepository.existsById(newsId)) { + throw new CustomException(NEWS_NOT_FOUND); + } + if (likedNewsRepository.existsByNewsIdAndSiteUserId(newsId, siteUserId)) { + throw new CustomException(ALREADY_LIKED_NEWS); + } + LikedNews likedNews = new LikedNews(newsId, siteUserId); + likedNewsRepository.save(likedNews); + } + + @Transactional + public void cancelNewsLike(long siteUserId, long newsId) { + if (!newsRepository.existsById(newsId)) { + throw new CustomException(NEWS_NOT_FOUND); + } + LikedNews likedNews = likedNewsRepository.findByNewsIdAndSiteUserId(newsId, siteUserId) + .orElseThrow(() -> new CustomException(NOT_LIKED_NEWS)); + likedNewsRepository.delete(likedNews); + } +} diff --git a/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java b/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java new file mode 100644 index 000000000..1bf26bc3c --- /dev/null +++ b/src/test/java/com/example/solidconnection/news/service/NewsLikeServiceTest.java @@ -0,0 +1,119 @@ +package com.example.solidconnection.news.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.news.domain.News; +import com.example.solidconnection.news.dto.LikedNewsResponse; +import com.example.solidconnection.news.fixture.NewsFixture; +import com.example.solidconnection.news.repository.LikedNewsRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_LIKED_NEWS; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_LIKED_NEWS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +@TestContainerSpringBootTest +@DisplayName("소식지 좋아요 서비스 테스트") +class NewsLikeServiceTest { + + @Autowired + private NewsLikeService newsLikeService; + + @Autowired + private LikedNewsRepository likedNewsRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private NewsFixture newsFixture; + + private SiteUser user; + private News news; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + news = newsFixture.소식지(siteUserFixture.멘토(1, "mentor").getId()); + } + + @Nested + class 소식지_좋아요_상태를_조회한다 { + + @Test + void 좋아요한_소식지의_좋아요_상태를_조회한다() { + // given + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // when + LikedNewsResponse response = newsLikeService.isNewsLiked(user.getId(), news.getId()); + + // then + assertThat(response.isLike()).isTrue(); + } + + @Test + void 좋아요하지_않은_소식지의_좋아요_상태를_조회한다() { + // when + LikedNewsResponse response = newsLikeService.isNewsLiked(user.getId(), news.getId()); + + // then + assertThat(response.isLike()).isFalse(); + } + } + + @Nested + class 소식지_좋아요를_등록한다 { + + @Test + void 성공적으로_좋아요를_등록한다() { + // when + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // then + assertThat(likedNewsRepository.existsByNewsIdAndSiteUserId(news.getId(), user.getId())).isTrue(); + } + + @Test + void 이미_좋아요했으면_예외_응답을_반환한다() { + // given + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // when & then + assertThatCode(() -> newsLikeService.addNewsLike(user.getId(), news.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_LIKED_NEWS.getMessage()); + } + } + + @Nested + class 소식지_좋아요를_취소한다 { + + @Test + void 성공적으로_좋아요를_취소한다() { + // given + newsLikeService.addNewsLike(user.getId(), news.getId()); + + // when + newsLikeService.cancelNewsLike(user.getId(), news.getId()); + + // then + assertThat(likedNewsRepository.existsByNewsIdAndSiteUserId(news.getId(), user.getId())).isFalse(); + } + + @Test + void 좋아요하지_않았으면_예외_응답을_반환한다() { + // when & then + assertThatCode(() -> newsLikeService.cancelNewsLike(user.getId(), news.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_LIKED_NEWS.getMessage()); + } + } +}