Repository: nhnacademy-be09-ink3/ink3-api
Pull request #31: feat: 리뷰 CRUD 구현
Author: login: Jihyun3478
Created: 2025-05-20T01:20:23Z | Merged: 2025-05-20T23:58:10Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced review management functionality, allowing users to add, retrieve, and delete reviews via new API endpoints.
  - Added support for paginated retrieval of reviews for a specific book.
- **Bug Fixes**
  - No user-facing bug fixes included.
- **Style**
  - Improved code formatting for better readability.
- **Tests**
  - Added comprehensive tests for review-related API endpoints, service logic, and repository queries to ensure reliability and correctness.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/api/src/main/java/shop/ink3/api/book/book/entity/Book.java b/api/src/main/java/shop/ink3/api/book/book/entity/Book.java
index 69161612..2ae9c5c2 100644
--- a/api/src/main/java/shop/ink3/api/book/book/entity/Book.java
+++ b/api/src/main/java/shop/ink3/api/book/book/entity/Book.java
@@ -69,20 +69,20 @@ public class Book {
 
     @Builder.Default
     @OneToMany(mappedBy = "book",
-            cascade = CascadeType.ALL,
-            orphanRemoval = true)
+        cascade = CascadeType.ALL,
+        orphanRemoval = true)
     private List<BookCategory> bookCategories = new ArrayList<>();
 
     @Builder.Default
     @OneToMany(mappedBy = "book",
-            cascade = CascadeType.ALL,
-            orphanRemoval = true)
+        cascade = CascadeType.ALL,
+        orphanRemoval = true)
     private List<BookAuthor> bookAuthors = new ArrayList<>();
 
     @Builder.Default
     @OneToMany(mappedBy = "book",
-            cascade = CascadeType.ALL,
-            orphanRemoval = true)
+        cascade = CascadeType.ALL,
+        orphanRemoval = true)
     private List<BookTag> bookTags = new ArrayList<>();
 
     @PrePersist
@@ -110,18 +110,18 @@ public void addBookTag(Tag tag) {
     }
 
     public void updateBook(
-            String ISBN,
-            String title,
-            String contents,
-            String description,
-            LocalDate publishedAt,
-            Integer originalPrice,
-            Integer salePrice,
-            Integer quantity,
-            BookStatus status,
-            boolean isPackable,
-            String thumbnailUrl,
-            Publisher publisher
+        String ISBN,
+        String title,
+        String contents,
+        String description,
+        LocalDate publishedAt,
+        Integer originalPrice,
+        Integer salePrice,
+        Integer quantity,
+        BookStatus status,
+        boolean isPackable,
+        String thumbnailUrl,
+        Publisher publisher
     ) {
         this.ISBN = ISBN;
         this.title = title;
diff --git a/api/src/main/java/shop/ink3/api/review/controller/ReviewController.java b/api/src/main/java/shop/ink3/api/review/controller/ReviewController.java
new file mode 100644
index 00000000..6c354b22
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/controller/ReviewController.java
@@ -0,0 +1,49 @@
+package shop.ink3.api.review.controller;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.service.ReviewService;
+
+@RestController
+@RequestMapping("/reviews")
+@RequiredArgsConstructor
+public class ReviewController {
+    private final ReviewService reviewService;
+
+    @PostMapping
+    public ResponseEntity<ReviewResponse> addReview(@RequestBody ReviewRequest request) {
+        return ResponseEntity.ok(reviewService.addReview(request));
+    }
+
+    @GetMapping("/user/{userId}")
+    public ResponseEntity<ReviewResponse> getReviewByUserId(@PathVariable Long userId) {
+        return ResponseEntity.ok(reviewService.getReviewByUserId(userId));
+    }
+
+    @GetMapping("/book/{bookId}")
+    public ResponseEntity<Page<ReviewResponse>> getReviewsByBookId(
+        @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
+        @PathVariable Long bookId) {
+        return ResponseEntity.ok(reviewService.getReviewsByBookId(pageable, bookId));
+    }
+
+    @DeleteMapping("/{id}")
+    public ResponseEntity<?> deleteReview(@PathVariable Long id) {
+        reviewService.deleteReview(id);
+        return ResponseEntity.noContent().build();
+    }
+}
diff --git a/api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java b/api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java
new file mode 100644
index 00000000..cd36e389
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java
@@ -0,0 +1,10 @@
+package shop.ink3.api.review.dto;
+
+public record ReviewRequest(
+    Long userId,
+    Long orderBookId,
+    String title,
+    String content,
+    int rating
+) {
+}
diff --git a/api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java b/api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java
new file mode 100644
index 00000000..593bd929
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java
@@ -0,0 +1,22 @@
+package shop.ink3.api.review.dto;
+
+import java.time.LocalDateTime;
+
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.user.user.entity.User;
+
+public record ReviewResponse(
+    Long id,
+    Long userId,
+    Long orderBookId,
+    String title,
+    String content,
+    int rating,
+    LocalDateTime createdAt
+) {
+    public static ReviewResponse from(Review review) {
+        return new ReviewResponse(review.getId(), review.getUser().getId(), review.getOrderBook().getId(), review.getTitle(),
+            review.getContent(), review.getRating(), review.getCreatedAt());
+    }
+}
diff --git a/api/src/main/java/shop/ink3/api/review/entity/Review.java b/api/src/main/java/shop/ink3/api/review/entity/Review.java
index 98658763..b946627d 100644
--- a/api/src/main/java/shop/ink3/api/review/entity/Review.java
+++ b/api/src/main/java/shop/ink3/api/review/entity/Review.java
@@ -12,11 +12,13 @@
 import jakarta.persistence.Table;
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
+import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import shop.ink3.api.order.orderBook.entity.OrderBook;
 import shop.ink3.api.user.user.entity.User;
 
+@Builder
 @Entity
 @Table(name = "reviews")
 @Getter
@@ -41,7 +43,7 @@ public class Review {
 
     private LocalDateTime createdAt;
 
-    public Review(User user, OrderBook orderBook, String title, String content, int rating, LocalDateTime createdAt) {
+    public Review(User user, OrderBook orderBook, String title, String content, int rating) {
         this.user = user;
         this.orderBook = orderBook;
         this.title = title;
diff --git a/api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java b/api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java
new file mode 100644
index 00000000..48a6e94f
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java
@@ -0,0 +1,7 @@
+package shop.ink3.api.review.exception;
+
+public class ReviewNotFoundException extends RuntimeException {
+    public ReviewNotFoundException(long reviewId) {
+        super("존재하지 않는 리뷰입니다. id: %d".formatted(reviewId));
+    }
+}
diff --git a/api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java b/api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java
new file mode 100644
index 00000000..9cedfebf
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java
@@ -0,0 +1,14 @@
+package shop.ink3.api.review.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import shop.ink3.api.review.entity.Review;
+
+public interface ReviewRepository extends JpaRepository<Review, Long> {
+    Review findReviewByUserId(Long userId);
+
+    Page<Review> findAllByOrderBook_BookId(Pageable pageable, Long bookId);
+}
diff --git a/api/src/main/java/shop/ink3/api/review/service/ReviewService.java b/api/src/main/java/shop/ink3/api/review/service/ReviewService.java
new file mode 100644
index 00000000..960a9787
--- /dev/null
+++ b/api/src/main/java/shop/ink3/api/review/service/ReviewService.java
@@ -0,0 +1,58 @@
+package shop.ink3.api.review.service;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import shop.ink3.api.order.orderBook.exception.OrderBookNotFoundException;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.exception.UserNotFoundException;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class ReviewService  {
+    private final UserRepository userRepository;
+    private final OrderBookRepository orderBookRepository;
+    private final ReviewRepository reviewRepository;
+
+    public ReviewResponse addReview(ReviewRequest request) {
+        User user = userRepository.findById(request.userId())
+            .orElseThrow(() -> new UserNotFoundException(request.userId()));
+        OrderBook orderBook = orderBookRepository.findById(request.orderBookId()).orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
+
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+        return ReviewResponse.from(reviewRepository.save(review));
+    }
+
+    public ReviewResponse getReviewByUserId(Long userId) {
+        Review review = reviewRepository.findReviewByUserId(userId);
+        return ReviewResponse.from(review);
+    }
+
+    public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long bookId) {
+        return reviewRepository.findAllByOrderBook_BookId(pageable, bookId)
+            .map(ReviewResponse::from);
+    }
+
+    public void deleteReview(Long id) {
+        reviewRepository.findById(id).orElseThrow(() -> new ReviewNotFoundException(id));
+        reviewRepository.deleteById(id);
+    }
+}
diff --git a/api/src/main/resources/application-test.yml b/api/src/main/resources/application-test.yml
index 5be91fc0..e5211578 100644
--- a/api/src/main/resources/application-test.yml
+++ b/api/src/main/resources/application-test.yml
@@ -25,4 +25,4 @@ logging:
   level:
     org.hibernate.SQL: debug
 aladin:
-  ttb-key: ${TTB_KEY}
\ No newline at end of file
+  ttb-key: ${TTB_KEY}
diff --git a/api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java b/api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java
new file mode 100644
index 00000000..8801de0d
--- /dev/null
+++ b/api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java
@@ -0,0 +1,178 @@
+package shop.ink3.api.review.controller;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import shop.ink3.api.book.book.entity.Book;
+import shop.ink3.api.book.book.entity.BookStatus;
+import shop.ink3.api.book.publisher.entity.Publisher;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.service.ReviewService;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.entity.UserStatus;
+
+@WebMvcTest(ReviewController.class)
+class ReviewControllerTest {
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockitoBean
+    private ReviewService reviewService;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    private User user;
+    private Publisher publisher;
+    private Book book;
+    private OrderBook orderBook1;
+    private OrderBook orderBook2;
+    private ReviewRequest reviewRequest;
+    private ReviewResponse reviewResponse;
+
+    @BeforeEach
+    void setUp() {
+        user = User.builder()
+            .id(1L)
+            .loginId("test")
+            .name("test")
+            .email("test@test.com")
+            .phone("010-1234-5678")
+            .birthday(LocalDate.of(2025, 1, 1))
+            .point(1000)
+            .status(UserStatus.ACTIVE)
+            .lastLoginAt(LocalDateTime.now())
+            .createdAt(LocalDateTime.now())
+            .build();
+
+        publisher = Publisher.builder()
+            .id(1L)
+            .name("출판사1")
+            .build();
+
+        book = Book.builder()
+            .id(1L)
+            .ISBN("1234567890123")
+            .title("예제 책 제목")
+            .contents("책 내용 요약")
+            .description("책 상세 설명")
+            .publishedAt(LocalDate.of(2024, 1, 1))
+            .originalPrice(20000)
+            .salePrice(18000)
+            .discountRate((18000 * 100) / 20000)
+            .quantity(100)
+            .status(BookStatus.AVAILABLE)
+            .isPackable(true)
+            .thumbnailUrl("https://example.com/image.jpg")
+            .publisher(publisher)
+            .build();
+
+        orderBook1 = OrderBook.builder()
+            .id(1L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        orderBook2 = OrderBook.builder()
+            .id(2L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        reviewRequest = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        reviewResponse = new ReviewResponse(1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now());
+    }
+
+    @Test
+    @DisplayName("리뷰 등록")
+    void addReview() throws Exception {
+        Mockito.when(reviewService.addReview(any(ReviewRequest.class))).thenReturn(reviewResponse);
+
+        mockMvc.perform(post("/reviews")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(reviewRequest)))
+            .andExpect(status().isOk())
+            .andExpect(jsonPath("$.id").value(1L))
+            .andExpect(jsonPath("$.title").value("title1"))
+            .andExpect(jsonPath("$.content").value("content1"));
+    }
+
+    @Test
+    @DisplayName("주문 도서의 리뷰 조회")
+    void getReviewByUserId() throws Exception {
+        ReviewResponse response = new ReviewResponse(
+            1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now()
+        );
+
+        when(reviewService.getReviewByUserId(user.getId())).thenReturn(response);
+
+        mockMvc.perform(get("/reviews/user/{userId}", user.getId()))
+            .andExpect(status().isOk())
+            .andExpect(jsonPath("$.id").value(1L))
+            .andExpect(jsonPath("$.title").value("title1"))
+            .andExpect(jsonPath("$.content").value("content1"))
+            .andExpect(jsonPath("$.rating").value(5));
+    }
+
+    @Test
+    @DisplayName("한 도서의 리뷰 전체 조회")
+    void getReviewsByBookId() throws Exception {
+        List<ReviewResponse> content = List.of(
+            new ReviewResponse(1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now()),
+            new ReviewResponse(2L, user.getId(), orderBook2.getId(), "title2", "content2", 4, LocalDateTime.now())
+        );
+
+        Page<ReviewResponse> reviewResponses = new PageImpl<>(
+            content,
+            PageRequest.of(0, 10),
+            content.size()
+        );
+
+        Mockito.when(reviewService.getReviewsByBookId(any(), eq(book.getId()))).thenReturn(reviewResponses);
+
+        mockMvc.perform(get("/reviews/book/1"))
+            .andExpect(status().isOk())
+            .andExpect(jsonPath("$.content.length()").value(2));
+    }
+
+    @Test
+    @DisplayName("리뷰 삭제")
+    void deleteReview() throws Exception {
+        doNothing().when(reviewService).deleteReview(1L);
+
+        mockMvc.perform(delete("/reviews/1"))
+            .andExpect(status().isNoContent());
+    }
+}
diff --git a/api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java b/api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java
new file mode 100644
index 00000000..c2df60ba
--- /dev/null
+++ b/api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java
@@ -0,0 +1,123 @@
+package shop.ink3.api.review.repository;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+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.orm.jpa.DataJpaTest;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.context.ActiveProfiles;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import shop.ink3.api.book.book.entity.Book;
+import shop.ink3.api.book.book.entity.BookStatus;
+import shop.ink3.api.book.publisher.entity.Publisher;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.user.membership.entity.Membership;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.entity.UserStatus;
+
+@DataJpaTest
+@ActiveProfiles("test")
+class ReviewRepositoryTest {
+
+    @Autowired
+    private ReviewRepository reviewRepository;
+
+    @PersistenceContext
+    private EntityManager em;
+
+    private User user;
+    private Book book;
+    private OrderBook orderBook;
+
+    @BeforeEach
+    void setUp() {
+        Publisher publisher = Publisher.builder()
+            .name("출판사1")
+            .build();
+        em.persist(publisher);
+
+        book = Book.builder()
+            .ISBN("1234567890123")
+            .title("책 제목")
+            .contents("내용 요약")
+            .description("설명")
+            .publishedAt(LocalDate.of(2024, 1, 1))
+            .originalPrice(20000)
+            .salePrice(18000)
+            .discountRate(10)
+            .quantity(100)
+            .status(BookStatus.AVAILABLE)
+            .isPackable(true)
+            .thumbnailUrl("url")
+            .publisher(publisher)
+            .build();
+        em.persist(book);
+
+        Membership membership = Membership.builder()
+            .name("멤버쉽1")
+            .conditionAmount(10000)
+            .pointRate(5)
+            .isActive(true)
+            .isDefault(true)
+            .createdAt(LocalDateTime.now())
+            .build();
+        em.persist(membership);
+
+        user = User.builder()
+            .loginId("test")
+            .password("test")
+            .name("홍길동")
+            .email("test@test.com")
+            .phone("010-1234-5678")
+            .birthday(LocalDate.of(1990, 1, 1))
+            .status(UserStatus.ACTIVE)
+            .point(1000)
+            .lastLoginAt(LocalDateTime.now())
+            .createdAt(LocalDateTime.now())
+            .membership(membership)
+            .build();
+        em.persist(user);
+
+        orderBook = OrderBook.builder()
+            .book(book)
+            .price(20000)
+            .quantity(1)
+            .build();
+        em.persist(orderBook);
+
+        Review review = new Review(user, orderBook, "좋아요", "추천합니다", 5);
+        em.persist(review);
+        em.flush();
+        em.clear();
+    }
+
+    @Test
+    @DisplayName("단일 리뷰 조회")
+    void findByUserId() {
+        Optional<Review> result = Optional.ofNullable(reviewRepository.findReviewByUserId(user.getId()));
+        assertThat(result).isPresent();
+        assertThat(result.get().getTitle()).isEqualTo("좋아요");
+    }
+
+    @Test
+    @DisplayName("한 도서에 대한 모든 리뷰 조회")
+    void findAllByOrderBook_BookId() {
+        Page<Review> page = reviewRepository.findAllByOrderBook_BookId(
+            PageRequest.of(0, 10), book.getId()
+        );
+        assertThat(page.getTotalElements()).isEqualTo(1);
+        assertThat(page.getContent().get(0).getRating()).isEqualTo(5);
+    }
+}
+
diff --git a/api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java b/api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java
new file mode 100644
index 00000000..deb5d835
--- /dev/null
+++ b/api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java
@@ -0,0 +1,230 @@
+package shop.ink3.api.review.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import shop.ink3.api.book.book.entity.Book;
+import shop.ink3.api.book.book.entity.BookStatus;
+import shop.ink3.api.book.publisher.entity.Publisher;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.entity.UserStatus;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+class ReviewServiceTest {
+    @Mock
+    private UserRepository userRepository;
+
+    @Mock
+    private OrderBookRepository orderBookRepository;
+
+    @Mock
+    private ReviewRepository reviewRepository;
+
+    @InjectMocks
+    private ReviewService reviewService;
+
+    private User user;
+    private Publisher publisher;
+    private Book book;
+    private OrderBook orderBook1;
+    private OrderBook orderBook2;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        user = User.builder()
+            .id(1L)
+            .loginId("test")
+            .name("test")
+            .email("test@test.com")
+            .phone("010-1234-5678")
+            .birthday(LocalDate.of(2025, 1, 1))
+            .point(1000)
+            .status(UserStatus.ACTIVE)
+            .lastLoginAt(LocalDateTime.now())
+            .createdAt(LocalDateTime.now())
+            .build();
+
+        publisher = Publisher.builder()
+            .id(1L)
+            .name("출판사1")
+            .build();
+
+        book = Book.builder()
+            .id(1L)
+            .ISBN("1234567890123")
+            .title("예제 책 제목")
+            .contents("책 내용 요약")
+            .description("책 상세 설명")
+            .publishedAt(LocalDate.of(2024, 1, 1))
+            .originalPrice(20000)
+            .salePrice(18000)
+            .discountRate((18000 * 100) / 20000)
+            .quantity(100)
+            .status(BookStatus.AVAILABLE)
+            .isPackable(true)
+            .thumbnailUrl("https://example.com/image.jpg")
+            .publisher(publisher)
+            .build();
+
+        orderBook1 = OrderBook.builder()
+            .id(1L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        orderBook2 = OrderBook.builder()
+            .id(2L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+    }
+
+    @Test
+    @DisplayName("리뷰 등록")
+    void addReview() {
+        ReviewRequest request = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+
+        ReflectionTestUtils.setField(review, "id", 1L);
+
+        when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
+        when(orderBookRepository.findById(orderBook1.getId())).thenReturn(Optional.of(orderBook1));
+        when(reviewRepository.save(any(Review.class))).thenReturn(review);
+
+        ReviewResponse response = reviewService.addReview(request);
+
+        assertThat(response.id()).isEqualTo(1L);
+        assertThat(response.title()).isEqualTo("title1");
+        assertThat(response.content()).isEqualTo("content1");
+    }
+
+    @Test
+    @DisplayName("주문 도서의 리뷰 조회")
+    void getReviewByUserId() {
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title("title1")
+            .content("content1")
+            .rating(5)
+            .build();
+        ReflectionTestUtils.setField(review, "id", 1L);
+
+        when(reviewRepository.findReviewByUserId(user.getId())).thenReturn(review);
+
+        ReviewResponse response = reviewService.getReviewByUserId(user.getId());
+
+        assertThat(response.id()).isEqualTo(1L);
+        assertThat(response.title()).isEqualTo("title1");
+        assertThat(response.content()).isEqualTo("content1");
+        assertThat(response.rating()).isEqualTo(5);
+    }
+
+    @Test
+    @DisplayName("한 도서의 리뷰 전체 조회")
+    void getReviewsByBookId() {
+        List<Review> reviews = new ArrayList<>();
+        ReviewRequest reviewRequest1 = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review1 = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(reviewRequest1.title())
+            .content(reviewRequest1.content())
+            .rating(reviewRequest1.rating())
+            .build();
+        ReflectionTestUtils.setField(review1, "id", 1L);
+
+        ReviewRequest reviewRequest2 = new ReviewRequest(user.getId(), orderBook2.getId(), "title2", "content2", 4);
+        Review review2 = Review.builder()
+            .user(user)
+            .orderBook(orderBook2)
+            .title(reviewRequest2.title())
+            .content(reviewRequest2.content())
+            .rating(reviewRequest2.rating())
+            .build();
+        ReflectionTestUtils.setField(review2, "id", 2L);
+
+        reviews.add(review1);
+        reviews.add(review2);
+
+        Page<Review> reviewPage = new PageImpl<>(reviews, PageRequest.of(0, 10), reviews.size());
+
+        when(reviewRepository.findAllByOrderBook_BookId(any(), eq(book.getId()))).thenReturn(reviewPage);
+
+        Page<ReviewResponse> responses = reviewService.getReviewsByBookId(PageRequest.of(0, 10), book.getId());
+
+        assertThat(responses).hasSize(2);
+        assertThat(responses.getContent().get(0).id()).isEqualTo(1L);
+        assertThat(responses.getContent().get(1).id()).isEqualTo(2L);
+    }
+
+    @Test
+    @DisplayName("리뷰 삭제 성공")
+    void deleteReviewSuccess() {
+        ReviewRequest reviewRequest = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(reviewRequest.title())
+            .content(reviewRequest.content())
+            .rating(reviewRequest.rating())
+            .build();
+
+        when(reviewRepository.findById(1L)).thenReturn(Optional.of(review));
+        when(reviewRepository.save(ArgumentMatchers.any(Review.class))).thenReturn(review);
+
+        reviewService.deleteReview(1L);
+        verify(reviewRepository).deleteById(1L);
+    }
+
+    @Test
+    @DisplayName("리뷰 삭제 실패")
+    void deleteReviewFailure() {
+        when(reviewRepository.findById(0L)).thenReturn(Optional.empty());
+
+        assertThatThrownBy(() -> reviewService.deleteReview(0L))
+            .isInstanceOf(ReviewNotFoundException.class)
+            .hasMessageContaining("존재하지 않는 리뷰입니다. id: ");
+    }
+}


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 6**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java (1)</summary><blockquote>

`3-9`: **Add input validation annotations to ReviewRequest**  
To enforce constraints at the API boundary and fail fast on invalid input, consider applying JSR-303 annotations:

```diff
 public record ReviewRequest(
-    Long userId,
-    Long orderBookId,
-    String title,
-    String content,
-    int rating
+)   @NotNull Long userId,
+)   @NotNull Long orderBookId,
+)   @NotBlank @Size(max = 100) String title,
+)   @NotBlank String content,
+)   @Min(1) @Max(5) int rating
 ) { }
```

Ensure the controller is annotated with `@Validated`.

</blockquote></details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java (1)</summary><blockquote>

`13-13`: **Align query method name with Spring Data conventions**  
Spring Data parses property paths via camel-case rather than underscores. Rename for clarity:

```diff
- Page<Review> findAllByOrderBook_BookId(Pageable pageable, Long bookId);
+ Page<Review> findAllByOrderBookBookId(Pageable pageable, Long bookId);
```

</blockquote></details>
<details>
<summary>api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java (1)</summary><blockquote>

`105-111`: **Consider returning Optional directly from repository method**

The need to wrap the repository result in `Optional.ofNullable()` suggests the repository method returns a nullable object instead of an Optional.


```diff
-        Optional<Review> result = Optional.ofNullable(reviewRepository.findByUserId(user.getId()));
+        Optional<Review> result = reviewRepository.findByUserId(user.getId());
```

Consider modifying the `ReviewRepository` interface to return `Optional<Review>` from the `findByUserId` method for better null safety and to follow Spring Data JPA conventions.

</blockquote></details>
<details>
<summary>api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java (1)</summary><blockquote>

`61-72`: **User test data uses a future date for birthday.**

The test data for the user entity contains a birthday set in the future (`LocalDate.of(2025, 1, 1)`), which might cause issues or be unrealistic for testing scenarios. Consider using a past date to better reflect real-world data.

```diff
-            .birthday(LocalDate.of(2025, 1, 1))
+            .birthday(LocalDate.of(2000, 1, 1))
```

</blockquote></details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/service/ReviewService.java (2)</summary><blockquote>

`27-40`: **Formatting inconsistency and line length in addReview method.**

There's a formatting inconsistency between how the user and orderBook repository calls are structured. Also, the orderBook retrieval line exceeds typical line length guidelines, making it less readable.

```diff
 public ReviewResponse addReview(ReviewRequest request) {
     User user = userRepository.findById(request.userId())
         .orElseThrow(() -> new UserNotFoundException(request.userId()));
-    OrderBook orderBook = orderBookRepository.findById(request.orderBookId()).orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
+    OrderBook orderBook = orderBookRepository.findById(request.orderBookId())
+        .orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
```

---

`20-25`: **Consider adding class-level @Transactional annotation.**

Since this service manages domain entities and interacts with multiple repositories, consider adding a class-level `@Transactional` annotation with appropriate settings (e.g., read-only for query methods). This ensures consistent transaction behavior across all service methods.

```diff
 @Service
 @RequiredArgsConstructor
+@Transactional(readOnly = true)
 public class ReviewService  {
     private final UserRepository userRepository;
     private final OrderBookRepository orderBookRepository;
     private final ReviewRepository reviewRepository;
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f45120600d8cb2613465b556a95cb4f2279a2a84 and a5ab1b21f189a9178e7e1a3a2d9ee30d50291b5a.

</details>

<details>
<summary>📒 Files selected for processing (12)</summary>

* `api/src/main/java/shop/ink3/api/book/book/entity/Book.java` (2 hunks)
* `api/src/main/java/shop/ink3/api/review/controller/ReviewController.java` (1 hunks)
* `api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java` (1 hunks)
* `api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java` (1 hunks)
* `api/src/main/java/shop/ink3/api/review/entity/Review.java` (2 hunks)
* `api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java` (1 hunks)
* `api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java` (1 hunks)
* `api/src/main/java/shop/ink3/api/review/service/ReviewService.java` (1 hunks)
* `api/src/main/resources/application-test.yml` (1 hunks)
* `api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java` (1 hunks)
* `api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java` (1 hunks)
* `api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java (1)</summary><blockquote>

<details>
<summary>api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java (1)</summary>

* `ReviewNotFoundException` (3-7)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (20)</summary><blockquote>

<details>
<summary>api/src/main/resources/application-test.yml (1)</summary>

`28-29`: **Add newline at end-of-file for POSIX compliance.**

Including a trailing newline prevents YAML parsing warnings and adheres to POSIX file formatting standards.

</details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java (1)</summary>

`3-6`: **Verify Java version compatibility for String.formatted usage**  
The use of `String#formatted` requires Java 15+. Please ensure the project’s target Java version supports this method, or consider using `String.format` for broader compatibility:

```diff
-        super("존재하지 않는 리뷰입니다. id: %d".formatted(reviewId));
+        super(String.format("존재하지 않는 리뷰입니다. id: %d", reviewId));
```

</details>
<details>
<summary>api/src/main/java/shop/ink3/api/book/book/entity/Book.java (1)</summary>

`71-73`: Also applies to: 77-79

</details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java (1)</summary>

`18-21`: **Approve static factory method for ReviewResponse**  
The `from(Review)` method cleanly encapsulates mapping logic from the entity to the DTO, improving readability and maintainability.

</details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/entity/Review.java (2)</summary>

`15-15`: **Good addition of @Builder annotation!**

Adding the `@Builder` annotation is a good choice for this entity class. It enables a fluent API for creating Review instances, which improves code readability when instantiating Review objects in service and test classes.



Also applies to: 21-21

---

`46-53`: **Well-implemented constructor with auto-setting timestamp!**

The constructor properly sets all the necessary fields and automatically initializes the `createdAt` timestamp to the current time, which ensures consistency and prevents errors from manually setting timestamps. This is a good practice that simplifies the client code and guards against incorrect creation dates.

</details>
<details>
<summary>api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java (5)</summary>

`37-47`: **Good use of WebMvcTest and MockBean for controller testing!**

The test class correctly uses `@WebMvcTest` to focus on testing the controller layer in isolation with `MockMvc`, and `@MockBean` to mock the service layer dependencies. This approach is ideal for unit testing controllers without involving the actual service implementation.

---

`56-115`: **Well-structured test setup with comprehensive test data!**

The test setup method creates all necessary entities and DTOs for testing. This thorough preparation ensures that the tests have all required data available, improving test reliability and readability.

---

`117-129`: **Well-implemented test for review creation endpoint!**

This test correctly verifies:
1. The method's ability to handle JSON requests
2. Proper response status code
3. The response JSON structure matches expected values

The use of Mockito to stub the service method is also appropriate.

---

`148-167`: **Good pagination test implementation!**

The test for fetching paginated reviews is well implemented:
1. Correctly creates a test page with sample data
2. Properly mocks the service response
3. Verifies the response status and content length

This ensures that the pagination functionality works as expected.

---

`169-176`: **Good test for delete endpoint!**

The delete endpoint test correctly:
1. Mocks the void service method using `doNothing().when()`
2. Verifies the "204 No Content" response status

This properly tests the controller's handling of deletion requests.

</details>
<details>
<summary>api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java (3)</summary>

`29-41`: **Good use of DataJpaTest for repository layer testing!**

The test class properly uses `@DataJpaTest` to set up a test database context and focus on testing the repository layer. Using `@ActiveProfiles("test")` also ensures that the test runs with the appropriate configuration.

---

`43-103`: **Well-structured test data setup using EntityManager!**

The `setUp` method thoroughly prepares the test database with all required entities:
1. Creates and persists all related entities (Publisher, Book, Membership, User, OrderBook)
2. Creates a Review connected to these entities
3. Properly flushes and clears the persistence context to ensure a clean state

This comprehensive setup ensures reliable and consistent test execution.

---

`113-121`: **Good pagination repository test!**

The test for the paginated query method correctly:
1. Calls the repository method with appropriate parameters
2. Verifies both the total count and the content of the returned page

This ensures that the pagination functionality works correctly at the repository level.

</details>
<details>
<summary>api/src/main/java/shop/ink3/api/review/controller/ReviewController.java (5)</summary>

`21-26`: **Well-structured controller with proper annotations!**

The controller is properly annotated with:
1. `@RestController` to indicate it handles REST requests
2. `@RequestMapping("/reviews")` to define the base path
3. `@RequiredArgsConstructor` for constructor injection of dependencies

This follows Spring best practices for controller implementation.

---

`27-30`: **Proper implementation of review creation endpoint!**

The endpoint correctly:
1. Uses `@PostMapping` to handle POST requests
2. Takes a `@RequestBody` to deserialize the JSON request
3. Returns a response entity with the appropriate status code

The implementation is concise and follows REST conventions.

---

`32-35`: **Good implementation of user-specific review retrieval!**

The endpoint properly uses:
1. `@GetMapping` with a path variable
2. `@PathVariable` to extract the user ID from the URL
3. Returns a well-structured response

This allows clients to easily retrieve reviews by user ID.

---

`37-42`: **Excellent implementation of paginated review retrieval!**

The endpoint for retrieving reviews by book ID:
1. Properly supports pagination with `@PageableDefault`
2. Sets a default sort by creation date in descending order (newest first)
3. Correctly combines path variables and request parameters

This implementation follows best practices for paginated API endpoints.

---

`44-48`: **Well-implemented deletion endpoint!**

The delete endpoint correctly:
1. Uses `@DeleteMapping` with a path variable
2. Returns a 204 No Content response
3. Properly delegates to the service layer

This follows REST conventions for resource deletion.

</details>
<details>
<summary>api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java (1)</summary>

`142-162`: <details>
<summary>❌ Incorrect review comment</summary>

**The getReviewByUserId test doesn't handle multiple reviews case.**

The test assumes that `findByUserId` returns a single review object, but in real scenarios, a user might have multiple reviews. Consider testing with a collection of reviews instead, or clarify if the repository method is intended to return just one specific review.

---

🏁 Script executed:

```shell
#!/bin/bash
# Let's check the actual implementation of the ReviewRepository

# First, let's check if there's a repository interface definition
fd --type file --exec grep -l "interface ReviewRepository" {}

# Now let's check the findByUserId method signature in ReviewRepository
fd --type file --exec grep -A 3 "findByUserId" {}
```

Length of output: 2724

---



</details>

**Incorrect assumption about multiple reviews**  
The repository’s findByUserId(_Long_) is declared to return a single Review, so the service (and its test) are intentionally handling only one review per user. Testing for multiple-review scenarios isn’t applicable given the current method signature.  


> Likely an incorrect or invalid review comment.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T01:26:39Z
user: login: moooooooonlight
body: 확인했습니다!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T01:39:21Z
user: login: junopo
body: 확인했습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T04:12:28Z

Review comments:
diff_hunk: @@ -0,0 +1,14 @@
+package shop.ink3.api.review.repository;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import shop.ink3.api.review.entity.Review;
+
+public interface ReviewRepository extends JpaRepository<Review, Long> {
+    Review findByUserId(Long userId);
path: api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Reconsider return type of findByUserId**  
Using `Review findByUserId(Long userId)` assumes a one-to-one relationship between users and reviews. If multiple reviews per user are allowed, this method will fail at runtime. Options:  
- Change to `List<Review> findByUserId(Long userId)` or use paging.  
- If exactly one review is expected, return `Optional<Review>`.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java at
line 11, the method findByUserId currently returns a single Review, which
assumes each user has only one review. To handle multiple reviews per user,
change the return type to List<Review> to return all reviews for the user, or
use a paging mechanism if needed. If only one review is expected, change the
return type to Optional<Review> to safely handle the absence of a review.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE
diff_hunk: @@ -0,0 +1,177 @@
+package shop.ink3.api.review.controller;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import shop.ink3.api.book.book.entity.Book;
+import shop.ink3.api.book.book.entity.BookStatus;
+import shop.ink3.api.book.publisher.entity.Publisher;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.service.ReviewService;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.entity.UserStatus;
+
+@WebMvcTest(ReviewController.class)
+class ReviewControllerTest {
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private ReviewService reviewService;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    private User user;
+    private Publisher publisher;
+    private Book book;
+    private OrderBook orderBook1;
+    private OrderBook orderBook2;
+    private ReviewRequest reviewRequest;
+    private ReviewResponse reviewResponse;
+
+    @BeforeEach
+    void setUp() {
+        user = User.builder()
+            .id(1L)
+            .loginId("test")
+            .name("test")
+            .email("test@test.com")
+            .phone("010-1234-5678")
+            .birthday(LocalDate.of(2025, 1, 1))
+            .point(1000)
+            .status(UserStatus.ACTIVE)
+            .lastLoginAt(LocalDateTime.now())
+            .createdAt(LocalDateTime.now())
+            .build();
+
+        publisher = Publisher.builder()
+            .id(1L)
+            .name("출판사1")
+            .build();
+
+        book = Book.builder()
+            .id(1L)
+            .ISBN("1234567890123")
+            .title("예제 책 제목")
+            .contents("책 내용 요약")
+            .description("책 상세 설명")
+            .publishedAt(LocalDate.of(2024, 1, 1))
+            .originalPrice(20000)
+            .salePrice(18000)
+            .discountRate((18000 * 100) / 20000)
+            .quantity(100)
+            .status(BookStatus.AVAILABLE)
+            .isPackable(true)
+            .thumbnailUrl("https://example.com/image.jpg")
+            .publisher(publisher)
+            .build();
+
+        orderBook1 = OrderBook.builder()
+            .id(1L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        orderBook2 = OrderBook.builder()
+            .id(2L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        reviewRequest = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        reviewResponse = new ReviewResponse(1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now());
+    }
+
+    @Test
+    @DisplayName("리뷰 등록")
+    void addReview() throws Exception {
+        Mockito.when(reviewService.addReview(any(ReviewRequest.class))).thenReturn(reviewResponse);
+
+        mockMvc.perform(post("/reviews")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(objectMapper.writeValueAsString(reviewRequest)))
+            .andExpect(status().isOk())
+            .andExpect(jsonPath("$.id").value(1L))
+            .andExpect(jsonPath("$.title").value("title1"))
+            .andExpect(jsonPath("$.content").value("content1"));
+    }
+
+    @Test
+    @DisplayName("주문 도서의 리뷰 조회")
+    void getReviewByUserId() throws Exception {
+        ReviewResponse response = new ReviewResponse(
+            1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now()
+        );
+
+        when(reviewService.getReviewByUserId(user.getId())).thenReturn(response);
+
+        mockMvc.perform(get("/reviews/user/1", user.getId()))
+            .andExpect(status().isOk())
+            .andExpect(jsonPath("$.id").value(1L))
+            .andExpect(jsonPath("$.title").value("title1"))
+            .andExpect(jsonPath("$.content").value("content1"))
+            .andExpect(jsonPath("$.rating").value(5));
+    }
path: api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**URL path in test doesn't match the path variable format**

There's a mismatch between the URL and how path variables are typically used in Spring MVC.


```diff
-        mockMvc.perform(get("/reviews/user/1", user.getId()))
+        mockMvc.perform(get("/reviews/user/{userId}", user.getId()))
```

The current format doesn't pass the path variable correctly. Spring MVC's `get()` method expects placeholders in the URL string to be replaced by variables in the order provided.


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    @Test
    @DisplayName("주문 도서의 리뷰 조회")
    void getReviewByUserId() throws Exception {
        ReviewResponse response = new ReviewResponse(
            1L, user.getId(), orderBook1.getId(), "title1", "content1", 5, LocalDateTime.now()
        );

        when(reviewService.getReviewByUserId(user.getId())).thenReturn(response);

        mockMvc.perform(get("/reviews/user/{userId}", user.getId()))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1L))
            .andExpect(jsonPath("$.title").value("title1"))
            .andExpect(jsonPath("$.content").value("content1"))
            .andExpect(jsonPath("$.rating").value(5));
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java
around lines 131 to 146, the test URL in mockMvc.perform uses a hardcoded path
"/reviews/user/1" but also passes user.getId() as a parameter, which is not
correctly applied as a path variable. Fix this by changing the URL string to use
a placeholder "/reviews/user/{userId}" and pass user.getId() as the argument to
replace the placeholder, ensuring the path variable is correctly injected in the
request URL.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE
diff_hunk: @@ -0,0 +1,229 @@
+package shop.ink3.api.review.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import shop.ink3.api.book.book.entity.Book;
+import shop.ink3.api.book.book.entity.BookStatus;
+import shop.ink3.api.book.publisher.entity.Publisher;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.entity.UserStatus;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+class ReviewServiceTest {
+    @Mock
+    private UserRepository userRepository;
+
+    @Mock
+    private OrderBookRepository orderBookRepository;
+
+    @Mock
+    private ReviewRepository reviewRepository;
+
+    @InjectMocks
+    private ReviewService reviewService;
+
+    private User user;
+    private Publisher publisher;
+    private Book book;
+    private OrderBook orderBook1;
+    private OrderBook orderBook2;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        user = User.builder()
+            .id(1L)
+            .loginId("test")
+            .name("test")
+            .email("test@test.com")
+            .phone("010-1234-5678")
+            .birthday(LocalDate.of(2025, 1, 1))
+            .point(1000)
+            .status(UserStatus.ACTIVE)
+            .lastLoginAt(LocalDateTime.now())
+            .createdAt(LocalDateTime.now())
+            .build();
+
+        publisher = Publisher.builder()
+            .id(1L)
+            .name("출판사1")
+            .build();
+
+        book = Book.builder()
+            .id(1L)
+            .ISBN("1234567890123")
+            .title("예제 책 제목")
+            .contents("책 내용 요약")
+            .description("책 상세 설명")
+            .publishedAt(LocalDate.of(2024, 1, 1))
+            .originalPrice(20000)
+            .salePrice(18000)
+            .discountRate((18000 * 100) / 20000)
+            .quantity(100)
+            .status(BookStatus.AVAILABLE)
+            .isPackable(true)
+            .thumbnailUrl("https://example.com/image.jpg")
+            .publisher(publisher)
+            .build();
+
+        orderBook1 = OrderBook.builder()
+            .id(1L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+
+        orderBook2 = OrderBook.builder()
+            .id(2L)
+            .order(null)
+            .book(book)
+            .packaging(null)
+            .couponStore(null)
+            .price(20000)
+            .quantity(100)
+            .build();
+    }
+
+    @Test
+    @DisplayName("리뷰 등록")
+    void addReview() {
+        ReviewRequest request = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+
+        ReflectionTestUtils.setField(review, "id", 1L);
+
+        when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
+        when(orderBookRepository.findById(orderBook1.getId())).thenReturn(Optional.of(orderBook1));
+        when(reviewRepository.save(any(Review.class))).thenReturn(review);
+
+        ReviewResponse response = reviewService.addReview(request);
+
+        assertThat(response.id()).isEqualTo(1L);
+        assertThat(response.title()).isEqualTo("title1");
+        assertThat(response.content()).isEqualTo("content1");
+    }
+
+    @Test
+    @DisplayName("주문 도서의 리뷰 조회")
+    void getReviewByUserId() {
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title("title1")
+            .content("content1")
+            .rating(5)
+            .build();
+        ReflectionTestUtils.setField(review, "id", 1L);
+
+        when(reviewRepository.findByUserId(user.getId())).thenReturn(review);
+
+        ReviewResponse response = reviewService.getReviewByUserId(user.getId());
+
+        assertThat(response.id()).isEqualTo(1L);
+        assertThat(response.title()).isEqualTo("title1");
+        assertThat(response.content()).isEqualTo("content1");
+        assertThat(response.rating()).isEqualTo(5);
+    }
+
+    @Test
+    @DisplayName("한 도서의 리뷰 전체 조회")
+    void getReviewsByBookId() {
+        List<Review> reviews = new ArrayList<>();
+        ReviewRequest reviewRequest1 = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review1 = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(reviewRequest1.title())
+            .content(reviewRequest1.content())
+            .rating(reviewRequest1.rating())
+            .build();
+        ReflectionTestUtils.setField(review1, "id", 1L);
+
+        ReviewRequest reviewRequest2 = new ReviewRequest(user.getId(), orderBook2.getId(), "title2", "content2", 4);
+        Review review2 = Review.builder()
+            .user(user)
+            .orderBook(orderBook2)
+            .title(reviewRequest2.title())
+            .content(reviewRequest2.content())
+            .rating(reviewRequest2.rating())
+            .build();
+        ReflectionTestUtils.setField(review2, "id", 2L);
+
+        reviews.add(review1);
+        reviews.add(review2);
+
+        Page<Review> reviewPage = new PageImpl<>(reviews, PageRequest.of(0, 10), reviews.size());
+
+        when(reviewRepository.findAllByOrderBook_BookId(any(), eq(book.getId()))).thenReturn(reviewPage);
+
+        Page<ReviewResponse> responses = reviewService.getReviewsByBookId(PageRequest.of(0, 10), book.getId());
+
+        assertThat(responses).hasSize(2);
+        assertThat(responses.getContent().get(0).id()).isEqualTo(1L);
+        assertThat(responses.getContent().get(1).id()).isEqualTo(2L);
+    }
+
+    @Test
+    @DisplayName("리뷰 삭제 성공")
+    void deleteReviewSuccess() {
+        ReviewRequest reviewRequest = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook1)
+            .title(reviewRequest.title())
+            .content(reviewRequest.content())
+            .rating(reviewRequest.rating())
+            .build();
+
+        when(reviewRepository.findById(1L)).thenReturn(Optional.of(review));
+        when(reviewRepository.save(ArgumentMatchers.any(Review.class))).thenReturn(review);
+
+        reviewService.deleteReview(1L);
+    }
path: api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Missing verification in deleteReviewSuccess test.**

The test sets up the mock behavior but doesn't verify that `reviewRepository.deleteById()` was actually called. This is important to ensure the service is correctly invoking the repository method.

```diff
 reviewService.deleteReview(1L);
+
+verify(reviewRepository).deleteById(1L);
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    @Test
    @DisplayName("리뷰 삭제 성공")
    void deleteReviewSuccess() {
        ReviewRequest reviewRequest = new ReviewRequest(user.getId(), orderBook1.getId(), "title1", "content1", 5);
        Review review = Review.builder()
            .user(user)
            .orderBook(orderBook1)
            .title(reviewRequest.title())
            .content(reviewRequest.content())
            .rating(reviewRequest.rating())
            .build();

        when(reviewRepository.findById(1L)).thenReturn(Optional.of(review));
        when(reviewRepository.save(ArgumentMatchers.any(Review.class))).thenReturn(review);

        reviewService.deleteReview(1L);
        verify(reviewRepository).deleteById(1L);
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java around
lines 202 to 218, the deleteReviewSuccess test sets up mocks but does not verify
that reviewRepository.deleteById() was called. Add a verification step using
Mockito's verify method to confirm that reviewRepository.deleteById(1L) is
invoked during the test to ensure the service correctly calls the repository
deletion method.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE
diff_hunk: @@ -0,0 +1,56 @@
+package shop.ink3.api.review.service;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import shop.ink3.api.order.orderBook.exception.OrderBookNotFoundException;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.exception.UserNotFoundException;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+@Service
+@RequiredArgsConstructor
+public class ReviewService  {
+    private final UserRepository userRepository;
+    private final OrderBookRepository orderBookRepository;
+    private final ReviewRepository reviewRepository;
+
+    public ReviewResponse addReview(ReviewRequest request) {
+        User user = userRepository.findById(request.userId())
+            .orElseThrow(() -> new UserNotFoundException(request.userId()));
+        OrderBook orderBook = orderBookRepository.findById(request.orderBookId()).orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
+
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+        return ReviewResponse.from(reviewRepository.save(review));
+    }
+
+    public ReviewResponse getReviewByUserId(Long userId) {
+        Review review = reviewRepository.findByUserId(userId);
+        return ReviewResponse.from(review);
+    }
+
+    public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long userId) {
+        return reviewRepository.findAllByOrderBook_BookId(pageable, userId)
+            .map(ReviewResponse::from);
+    }
path: api/src/main/java/shop/ink3/api/review/service/ReviewService.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Parameter name inconsistency in getReviewsByBookId method.**

The method parameter is named `userId` but it's being used as a book ID in the repository call. This inconsistency could lead to confusion and potential bugs.

```diff
-public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long userId) {
-    return reviewRepository.findAllByOrderBook_BookId(pageable, userId)
+public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long bookId) {
+    return reviewRepository.findAllByOrderBook_BookId(pageable, bookId)
         .map(ReviewResponse::from);
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long bookId) {
        return reviewRepository.findAllByOrderBook_BookId(pageable, bookId)
            .map(ReviewResponse::from);
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/main/java/shop/ink3/api/review/service/ReviewService.java around
lines 47 to 50, the method getReviewsByBookId has a parameter named userId that
is incorrectly used as a book ID in the repository call. Rename the parameter
from userId to bookId to correctly reflect its purpose and avoid confusion or
bugs.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE
diff_hunk: @@ -0,0 +1,56 @@
+package shop.ink3.api.review.service;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import shop.ink3.api.order.orderBook.exception.OrderBookNotFoundException;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.exception.UserNotFoundException;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+@Service
+@RequiredArgsConstructor
+public class ReviewService  {
+    private final UserRepository userRepository;
+    private final OrderBookRepository orderBookRepository;
+    private final ReviewRepository reviewRepository;
+
+    public ReviewResponse addReview(ReviewRequest request) {
+        User user = userRepository.findById(request.userId())
+            .orElseThrow(() -> new UserNotFoundException(request.userId()));
+        OrderBook orderBook = orderBookRepository.findById(request.orderBookId()).orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
+
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+        return ReviewResponse.from(reviewRepository.save(review));
+    }
+
+    public ReviewResponse getReviewByUserId(Long userId) {
+        Review review = reviewRepository.findByUserId(userId);
+        return ReviewResponse.from(review);
+    }
+
+    public Page<ReviewResponse> getReviewsByBookId(Pageable pageable, Long userId) {
+        return reviewRepository.findAllByOrderBook_BookId(pageable, userId)
+            .map(ReviewResponse::from);
+    }
+
+    public void deleteReview(Long id) {
+        reviewRepository.findById(id).orElseThrow(() -> new ReviewNotFoundException(id));
+        reviewRepository.deleteById(id);
+    }
path: api/src/main/java/shop/ink3/api/review/service/ReviewService.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Consider adding @Transactional annotation for data modification operations.**

The `deleteReview` method performs a database modification, but there's no transaction management annotation. Consider adding `@Transactional` to ensure data integrity, especially if the service is expanded to include more complex operations.

```diff
+@Transactional
 public void deleteReview(Long id) {
     reviewRepository.findById(id).orElseThrow(() -> new ReviewNotFoundException(id));
     reviewRepository.deleteById(id);
 }
```

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/main/java/shop/ink3/api/review/service/ReviewService.java around
lines 52 to 55, the deleteReview method modifies the database but lacks
transaction management. Add the @Transactional annotation to this method to
ensure that the delete operation is executed within a transaction, maintaining
data integrity and consistency.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE
diff_hunk: @@ -0,0 +1,56 @@
+package shop.ink3.api.review.service;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import shop.ink3.api.order.orderBook.exception.OrderBookNotFoundException;
+import shop.ink3.api.order.orderBook.entity.OrderBook;
+import shop.ink3.api.order.orderBook.repository.OrderBookRepository;
+import shop.ink3.api.review.exception.ReviewNotFoundException;
+import shop.ink3.api.review.dto.ReviewRequest;
+import shop.ink3.api.review.dto.ReviewResponse;
+import shop.ink3.api.review.entity.Review;
+import shop.ink3.api.review.repository.ReviewRepository;
+import shop.ink3.api.user.user.entity.User;
+import shop.ink3.api.user.user.exception.UserNotFoundException;
+import shop.ink3.api.user.user.repository.UserRepository;
+
+@Service
+@RequiredArgsConstructor
+public class ReviewService  {
+    private final UserRepository userRepository;
+    private final OrderBookRepository orderBookRepository;
+    private final ReviewRepository reviewRepository;
+
+    public ReviewResponse addReview(ReviewRequest request) {
+        User user = userRepository.findById(request.userId())
+            .orElseThrow(() -> new UserNotFoundException(request.userId()));
+        OrderBook orderBook = orderBookRepository.findById(request.orderBookId()).orElseThrow(() -> new OrderBookNotFoundException(request.orderBookId()));
+
+        Review review = Review.builder()
+            .user(user)
+            .orderBook(orderBook)
+            .title(request.title())
+            .content(request.content())
+            .rating(request.rating())
+            .build();
+        return ReviewResponse.from(reviewRepository.save(review));
+    }
+
+    public ReviewResponse getReviewByUserId(Long userId) {
+        Review review = reviewRepository.findByUserId(userId);
+        return ReviewResponse.from(review);
+    }
path: api/src/main/java/shop/ink3/api/review/service/ReviewService.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Potential issue with getReviewByUserId implementation.**

The current implementation assumes a user has exactly one review, as it returns a single Review object rather than a collection. This might not handle real-world scenarios where users can have multiple reviews or no reviews at all. Consider returning a List<ReviewResponse> or Optional<ReviewResponse>.

```diff
-public ReviewResponse getReviewByUserId(Long userId) {
-    Review review = reviewRepository.findByUserId(userId);
-    return ReviewResponse.from(review);
+public List<ReviewResponse> getReviewByUserId(Long userId) {
+    List<Review> reviews = reviewRepository.findAllByUserId(userId);
+    return reviews.stream()
+        .map(ReviewResponse::from)
+        .collect(Collectors.toList());
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    public List<ReviewResponse> getReviewByUserId(Long userId) {
        List<Review> reviews = reviewRepository.findAllByUserId(userId);
        return reviews.stream()
            .map(ReviewResponse::from)
            .collect(Collectors.toList());
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In api/src/main/java/shop/ink3/api/review/service/ReviewService.java around
lines 42 to 45, the method getReviewByUserId currently returns a single
ReviewResponse, assuming one review per user. To handle multiple or no reviews
properly, modify the method to return a List<ReviewResponse> by fetching all
reviews for the user from the repository, converting each Review to
ReviewResponse, and returning the list. Adjust the repository method to return a
collection of reviews if needed.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T01:26:38Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-20T01:20:29Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on base/target branches other than the default branch.
> 
> 
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This update introduces a complete review management feature to the application. It adds new entities, DTOs, repository, service, and controller classes for handling reviews, including endpoints for creating, retrieving, listing, and deleting reviews. Comprehensive unit and integration tests for the review domain are also provided. Minor formatting and newline adjustments are made elsewhere.

## Changes

| File(s)                                                                                           | Change Summary                                                                                                                                                                                                                                    |
|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `api/src/main/java/shop/ink3/api/review/controller/ReviewController.java`                         | Added a new REST controller with endpoints to create, retrieve (by user and book), and delete reviews, delegating logic to the service layer.                                                                                                     |
| `api/src/main/java/shop/ink3/api/review/dto/ReviewRequest.java`                                   | Introduced an immutable record for encapsulating review creation request data.                                                                                                                              |
| `api/src/main/java/shop/ink3/api/review/dto/ReviewResponse.java`                                  | Added an immutable record for review response data, including a static factory method for entity-to-DTO conversion.                                                                                        |
| `api/src/main/java/shop/ink3/api/review/entity/Review.java`                                       | Annotated with Lombok's `@Builder`, modified constructor to set `createdAt` internally, removed `createdAt` from constructor parameters.                                                                   |
| `api/src/main/java/shop/ink3/api/review/exception/ReviewNotFoundException.java`                   | Introduced a custom exception for handling missing reviews by ID.                                                                                                                                           |
| `api/src/main/java/shop/ink3/api/review/repository/ReviewRepository.java`                         | Added a Spring Data JPA repository interface with methods to find reviews by user ID and by book ID (paginated).                                                                                           |
| `api/src/main/java/shop/ink3/api/review/service/ReviewService.java`                               | Implemented a service for review creation, retrieval, listing, and deletion, with validation and exception handling.                                                                                       |
| `api/src/test/java/shop/ink3/api/review/controller/ReviewControllerTest.java`                     | Added tests for all controller endpoints using MockMvc and service mocking.                                                                                                                                 |
| `api/src/test/java/shop/ink3/api/review/repository/ReviewRepositoryTest.java`                     | Added repository tests for finding reviews by user ID and by book ID with pagination.                                                                                                                      |
| `api/src/test/java/shop/ink3/api/review/service/ReviewServiceTest.java`                           | Added service-level tests covering creation, retrieval, listing, and deletion of reviews, including failure scenarios.                                                                                     |
| `api/src/main/java/shop/ink3/api/book/book/entity/Book.java`                                      | Reformatted annotation parameters and method parameter lists for improved code style; no logic changes.                                                                                                     |
| `api/src/main/resources/application-test.yml`                                                     | Added a newline at the end of the file.                                                                                                                                                                    |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant ReviewController
    participant ReviewService
    participant UserRepository
    participant OrderBookRepository
    participant ReviewRepository

    Client->>ReviewController: POST /reviews (ReviewRequest)
    ReviewController->>ReviewService: addReview(request)
    ReviewService->>UserRepository: findById(request.userId)
    ReviewService->>OrderBookRepository: findById(request.orderBookId)
    ReviewService->>ReviewRepository: save(new Review)
    ReviewService-->>ReviewController: ReviewResponse
    ReviewController-->>Client: 200 OK (ReviewResponse)

    Client->>ReviewController: GET /reviews/user/{userId}
    ReviewController->>ReviewService: getReviewByUserId(userId)
    ReviewService->>ReviewRepository: findByUserId(userId)
    ReviewService-->>ReviewController: ReviewResponse
    ReviewController-->>Client: 200 OK (ReviewResponse)

    Client->>ReviewController: GET /reviews/book/{bookId}
    ReviewController->>ReviewService: getReviewsByBookId(pageable, bookId)
    ReviewService->>ReviewRepository: findAllByOrderBook_BookId(pageable, bookId)
    ReviewService-->>ReviewController: Page<ReviewResponse>
    ReviewController-->>Client: 200 OK (Page<ReviewResponse>)

    Client->>ReviewController: DELETE /reviews/{id}
    ReviewController->>ReviewService: deleteReview(id)
    ReviewService->>ReviewRepository: findById(id)
    ReviewService->>ReviewRepository: deleteById(id)
    ReviewService-->>ReviewController: void
    ReviewController-->>Client: 204 No Content
```

## Poem

> In the warren of code, new reviews now hop,  
> With builders and records, they never will stop.  
> From service to controller, their journey’s a breeze,  
> With tests all around, the rabbits are pleased!  
> So let’s thump our paws for this feature so neat—  
> Reviews are now live, and the circle’s complete!  
> 🐇✨

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=nhnacademy-be09-ink3/ink3-api&utm_content=31):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE