diff --git a/src/main/java/com/jisungin/api/review/ReviewController.java b/src/main/java/com/jisungin/api/review/ReviewController.java new file mode 100644 index 0000000..884effb --- /dev/null +++ b/src/main/java/com/jisungin/api/review/ReviewController.java @@ -0,0 +1,33 @@ +package com.jisungin.api.review; + +import com.jisungin.api.ApiResponse; +import com.jisungin.api.oauth.Auth; +import com.jisungin.api.oauth.AuthContext; +import com.jisungin.api.review.request.ReviewCreateRequest; +import com.jisungin.application.review.ReviewService; +import com.jisungin.application.review.response.ReviewResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/v1/reviews") +@RequiredArgsConstructor +@RestController +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + public ApiResponse createReview(@Valid @RequestBody ReviewCreateRequest request, + @Auth AuthContext authContext) { + return ApiResponse.ok(reviewService.createReview(request.toServiceRequest(), authContext.getUserId())); + } + + @DeleteMapping("/{reviewId}") + public ApiResponse deleteReview(@PathVariable Long reviewId, + @Auth AuthContext authContext) { + reviewService.deleteReview(reviewId, authContext.getUserId()); + return ApiResponse.ok(null); + } + +} diff --git a/src/main/java/com/jisungin/api/review/request/ReviewCreateRequest.java b/src/main/java/com/jisungin/api/review/request/ReviewCreateRequest.java new file mode 100644 index 0000000..b4167e3 --- /dev/null +++ b/src/main/java/com/jisungin/api/review/request/ReviewCreateRequest.java @@ -0,0 +1,37 @@ +package com.jisungin.api.review.request; + +import com.jisungin.application.review.request.ReviewCreateServiceRequest; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ReviewCreateRequest { + + @NotBlank(message = "리뷰 작성 시 책 isbn은 필수입니다.") + private String bookIsbn; + + @NotBlank(message = "리뷰 작성 시 내용은 필수입니다.") + private String content; + + @NotBlank(message = "리뷰 작성 시 별점은 필수입니다.") + private String rating; + + @Builder + private ReviewCreateRequest(String bookIsbn, String content, String rating) { + this.bookIsbn = bookIsbn; + this.content = content; + this.rating = rating; + } + + public ReviewCreateServiceRequest toServiceRequest() { + return ReviewCreateServiceRequest.builder() + .bookIsbn(bookIsbn) + .content(content) + .rating(Double.parseDouble(rating)) + .build(); + } + +} diff --git a/src/main/java/com/jisungin/application/review/ReviewService.java b/src/main/java/com/jisungin/application/review/ReviewService.java new file mode 100644 index 0000000..dc2a599 --- /dev/null +++ b/src/main/java/com/jisungin/application/review/ReviewService.java @@ -0,0 +1,56 @@ +package com.jisungin.application.review; + +import com.jisungin.application.review.request.ReviewCreateServiceRequest; +import com.jisungin.application.review.response.ReviewResponse; +import com.jisungin.domain.book.Book; +import com.jisungin.domain.book.repository.BookRepository; +import com.jisungin.domain.review.Review; +import com.jisungin.domain.review.repository.ReviewRepository; +import com.jisungin.domain.user.User; +import com.jisungin.domain.user.repository.UserRepository; +import com.jisungin.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.jisungin.exception.ErrorCode.*; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final BookRepository bookRepository; + + @Transactional + public ReviewResponse createReview(ReviewCreateServiceRequest request, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(USER_NOT_FOUND)); + + Book book = bookRepository.findById(request.getBookIsbn()) + .orElseThrow(() -> new BusinessException(BOOK_NOT_FOUND)); + + Review savedReview = reviewRepository.save(Review.create( + user, book, request.getContent(), request.getRating() + )); + return ReviewResponse.of(savedReview.getBook(), savedReview.getContent(), savedReview.getRating()); + } + + @Transactional + public void deleteReview(Long reviewId, Long userId) { + Review deleteReview = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(REVIEW_NOT_FOUND)); + + User reviewUser = deleteReview.getUser(); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(USER_NOT_FOUND)); + + if (!user.isMe(reviewUser.getId())) { + throw new BusinessException(UNAUTHORIZED_REQUEST); + } + reviewRepository.delete(deleteReview); + } + +} diff --git a/src/main/java/com/jisungin/application/review/request/ReviewCreateServiceRequest.java b/src/main/java/com/jisungin/application/review/request/ReviewCreateServiceRequest.java new file mode 100644 index 0000000..212fdb6 --- /dev/null +++ b/src/main/java/com/jisungin/application/review/request/ReviewCreateServiceRequest.java @@ -0,0 +1,24 @@ +package com.jisungin.application.review.request; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ReviewCreateServiceRequest { + + private String bookIsbn; + + private String content; + + private Double rating; + + @Builder + public ReviewCreateServiceRequest(String bookIsbn, String content, Double rating) { + this.bookIsbn = bookIsbn; + this.content = content; + this.rating = rating; + } + +} diff --git a/src/main/java/com/jisungin/application/review/response/ReviewResponse.java b/src/main/java/com/jisungin/application/review/response/ReviewResponse.java new file mode 100644 index 0000000..7144deb --- /dev/null +++ b/src/main/java/com/jisungin/application/review/response/ReviewResponse.java @@ -0,0 +1,33 @@ +package com.jisungin.application.review.response; + +import com.jisungin.domain.book.Book; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ReviewResponse { + + private Book book; + + private String content; + + private Double rating; + + @Builder + private ReviewResponse(Book book, String content, Double rating) { + this.book = book; + this.content = content; + this.rating = rating; + } + + public static ReviewResponse of(Book book, String content, Double rating) { + return ReviewResponse.builder() + .book(book) + .content(content) + .rating(rating) + .build(); + } + +} diff --git a/src/main/java/com/jisungin/domain/review/Review.java b/src/main/java/com/jisungin/domain/review/Review.java index c3766bd..a45cf28 100644 --- a/src/main/java/com/jisungin/domain/review/Review.java +++ b/src/main/java/com/jisungin/domain/review/Review.java @@ -42,4 +42,13 @@ private Review(User user, Book book, String content, Double rating) { this.rating = rating; } + public static Review create(User user, Book book, String content, Double rating) { + return Review.builder() + .user(user) + .book(book) + .content(content) + .rating(rating) + .build(); + } + } diff --git a/src/main/java/com/jisungin/exception/ErrorCode.java b/src/main/java/com/jisungin/exception/ErrorCode.java index 35b14b8..f8d5bc2 100644 --- a/src/main/java/com/jisungin/exception/ErrorCode.java +++ b/src/main/java/com/jisungin/exception/ErrorCode.java @@ -15,7 +15,8 @@ public enum ErrorCode { OAUTH_TYPE_NOT_FOUND(404, "지원하지 않는 소셜 로그인입니다."), TALK_ROOM_NOT_FOUND(400, "토크방을 찾을 수 없습니다."), UNAUTHORIZED_REQUEST(400, "권한이 없는 사용자입니다."), - COMMENT_NOT_FOUND(404, "의견을 찾을 수 없습니다."); + COMMENT_NOT_FOUND(404, "의견을 찾을 수 없습니다."), + REVIEW_NOT_FOUND(404, "리뷰를 찾을 수 없습니다."); private final int code; diff --git a/src/test/java/com/jisungin/ControllerTestSupport.java b/src/test/java/com/jisungin/ControllerTestSupport.java index 4666d3b..5928924 100644 --- a/src/test/java/com/jisungin/ControllerTestSupport.java +++ b/src/test/java/com/jisungin/ControllerTestSupport.java @@ -3,8 +3,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jisungin.api.comment.CommentController; import com.jisungin.api.oauth.AuthContext; +import com.jisungin.api.review.ReviewController; import com.jisungin.api.talkroom.TalkRoomController; import com.jisungin.application.comment.CommentService; +import com.jisungin.application.review.ReviewService; import com.jisungin.application.talkroom.TalkRoomService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -13,7 +15,8 @@ @WebMvcTest(controllers = { TalkRoomController.class, - CommentController.class + CommentController.class, + ReviewController.class }) public abstract class ControllerTestSupport { @@ -23,6 +26,9 @@ public abstract class ControllerTestSupport { @Autowired protected ObjectMapper objectMapper; + @MockBean + protected AuthContext authContext; + @MockBean protected TalkRoomService talkRoomService; @@ -30,5 +36,6 @@ public abstract class ControllerTestSupport { protected CommentService commentService; @MockBean - protected AuthContext authContext; + protected ReviewService reviewService; + } diff --git a/src/test/java/com/jisungin/api/review/ReviewControllerTest.java b/src/test/java/com/jisungin/api/review/ReviewControllerTest.java new file mode 100644 index 0000000..f66310f --- /dev/null +++ b/src/test/java/com/jisungin/api/review/ReviewControllerTest.java @@ -0,0 +1,114 @@ +package com.jisungin.api.review; + +import com.jisungin.ControllerTestSupport; +import com.jisungin.api.review.request.ReviewCreateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ReviewControllerTest extends ControllerTestSupport { + + @DisplayName("유저가 리뷰를 등록한다.") + @Test + void createReview() throws Exception { + //given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .bookIsbn("123456") + .content("재밌어요.") + .rating("4.5") + .build(); + + //when //then + mockMvc.perform( + post("/v1/reviews") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andDo(print()); + } + + @DisplayName("책 isbn과 함께 리뷰를 등록해야 한다.") + @Test + void createReviewWithoutBookIsbn() throws Exception { + //given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .content("재밌어요.") + .rating("4.5") + .build(); + + //when //then + mockMvc.perform( + post("/v1/reviews") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400")) + .andExpect(jsonPath("$.message").value("리뷰 작성 시 책 isbn은 필수입니다.")) + .andDo(print()); + } + + @DisplayName("리뷰 내용과 함께 리뷰를 등록해야 한다.") + @Test + void createReviewWithoutContent() throws Exception { + //given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .bookIsbn("123456") + .rating("4.5") + .build(); + + //when //then + mockMvc.perform( + post("/v1/reviews") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400")) + .andExpect(jsonPath("$.message").value("리뷰 작성 시 내용은 필수입니다.")) + .andDo(print()); + } + + @DisplayName("별점과 함께 리뷰를 등록해야 한다.") + @Test + void createReviewWithoutRating() throws Exception { + //given + ReviewCreateRequest request = ReviewCreateRequest.builder() + .bookIsbn("123456") + .content("재밌어요.") + .build(); + + //when //then + mockMvc.perform( + post("/v1/reviews") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("400")) + .andExpect(jsonPath("$.message").value("리뷰 작성 시 별점은 필수입니다.")) + .andDo(print()); + } + + @DisplayName("리뷰를 삭제한다.") + @Test + void deleteReview() throws Exception { + //given + Long deleteReviewId = 1L; + + //when //then + mockMvc.perform( + delete("/v1/reviews/{reviewId}", deleteReviewId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("OK")) + .andExpect(jsonPath("$.message").value("OK")) + .andDo(print()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/jisungin/application/review/ReviewServiceTest.java b/src/test/java/com/jisungin/application/review/ReviewServiceTest.java new file mode 100644 index 0000000..c461b6c --- /dev/null +++ b/src/test/java/com/jisungin/application/review/ReviewServiceTest.java @@ -0,0 +1,180 @@ +package com.jisungin.application.review; + +import com.jisungin.ServiceTestSupport; +import com.jisungin.application.review.request.ReviewCreateServiceRequest; +import com.jisungin.application.review.response.ReviewResponse; +import com.jisungin.domain.book.Book; +import com.jisungin.domain.book.repository.BookRepository; +import com.jisungin.domain.oauth.OauthId; +import com.jisungin.domain.oauth.OauthType; +import com.jisungin.domain.review.Review; +import com.jisungin.domain.review.repository.ReviewRepository; +import com.jisungin.domain.user.User; +import com.jisungin.domain.user.repository.UserRepository; +import com.jisungin.exception.BusinessException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class ReviewServiceTest extends ServiceTestSupport { + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private ReviewService reviewService; + + @AfterEach + void tearDown() { + reviewRepository.deleteAllInBatch(); + bookRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @DisplayName("유저가 리뷰를 등록한다.") + @Test + void createReview() { + //given + User user = createUser("1"); + userRepository.save(user); + + Book book = createBook(); + bookRepository.save(book); + + ReviewCreateServiceRequest request = ReviewCreateServiceRequest.builder() + .bookIsbn(book.getIsbn()) + .content("내용이 좋아요.") + .rating(4.5) + .build(); + + //when + ReviewResponse reviewResponse = reviewService.createReview(request, user.getId()); + + //then + List reviews = reviewRepository.findAll(); + + assertThat(reviewResponse.getBook()) + .extracting("isbn", "title", "content") + .contains("123456", "제목", "내용"); + + assertThat(reviews).hasSize(1) + .extracting("content", "rating") + .contains( + tuple("내용이 좋아요.", 4.5) + ); + + } + + @DisplayName("유저가 리뷰를 등록하는 책이 존재해야 한다") + @Test + void createReviewWithoutBook() { + //given + User user = createUser("1"); + userRepository.save(user); + + ReviewCreateServiceRequest request = ReviewCreateServiceRequest.builder() + .bookIsbn("123457") + .build(); + + //when //then + assertThatThrownBy(() -> reviewService.createReview(request, user.getId())) + .isInstanceOf(BusinessException.class) + .hasMessage("책을 찾을 수 없습니다."); + } + + @DisplayName("리뷰를 등록하는 유저가 존재해야 한다.") + @Test + void createReviewWithoutUser() { + //given + ReviewCreateServiceRequest request = ReviewCreateServiceRequest.builder() + .build(); + + //when //then + assertThatThrownBy(() -> reviewService.createReview(request, 1L)) + .isInstanceOf(BusinessException.class) + .hasMessage("사용자를 찾을 수 없습니다."); + } + + @DisplayName("리뷰를 삭제한다.") + @Test + void deleteReview() { + //given + User user = createUser("1"); + Book book = createBook(); + Review review = createReview(user, book); + userRepository.save(user); + bookRepository.save(book); + reviewRepository.save(review); + + //when + reviewService.deleteReview(review.getId(), user.getId()); + + //then + assertThat(reviewRepository.findAll()).isEmpty(); + } + + @DisplayName("다른 유저가 리뷰를 삭제한다.") + @Test + void deleteReviewWithAnotherUser() { + //given + User user1 = createUser("1"); + User user2 = createUser("2"); + Book book = createBook(); + Review review = createReview(user1, book); + userRepository.saveAll(List.of(user1, user2)); + bookRepository.save(book); + reviewRepository.save(review); + + //when //then + assertThatThrownBy(() -> reviewService.deleteReview(review.getId(), user2.getId())) + .isInstanceOf(BusinessException.class) + .hasMessage("권한이 없는 사용자입니다."); + } + + private static Review createReview(User user, Book book) { + return Review.builder() + .user(user) + .book(book) + .content("내용") + .rating(4.5) + .build(); + } + + private static User createUser(String oauthId) { + return User.builder() + .name("김도형") + .profileImage("image") + .oauthId( + OauthId.builder() + .oauthId(oauthId) + .oauthType(OauthType.KAKAO) + .build() + ) + .build(); + } + + private static Book createBook() { + return Book.builder() + .title("제목") + .content("내용") + .authors("김도형") + .isbn("123456") + .publisher("지성인") + .dateTime(LocalDateTime.of(2024, 1, 1, 0, 0)) + .imageUrl("image") + .build(); + } + +} \ No newline at end of file