diff --git a/build.gradle b/build.gradle index 313253cfd..e21db96e9 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.apache.commons:commons-lang3:3.13.0' implementation 'org.apache.commons:commons-collections4:4.4' + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java b/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java index 91d6d9c2f..11bedf863 100644 --- a/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java +++ b/src/main/java/org/c4marathon/assignment/domain/consumer/service/ConsumerService.java @@ -107,10 +107,18 @@ public void confirmOrder(Long orderId, Consumer consumer) { order.updateOrderStatus(CONFIRM); List orderProducts = orderProductReadService.findByOrderJoinFetchProductAndSeller(orderId); addSellerBalance(orderProducts); + addOrderCount(orderProducts); savePointLog(consumer, order, true); } + /** + * 주문 시 product에 대한 구매 횟수를 증가함 + */ + private void addOrderCount(List orderProducts) { + orderProducts.forEach(orderProduct -> orderProduct.getProduct().increaseOrderCount()); + } + private void saveOrderInfo(PurchaseProductRequest request, Consumer consumer, Order order, List orderProducts, long totalAmount) { orderProductJdbcRepository.saveAllBatch(orderProducts); diff --git a/src/main/java/org/c4marathon/assignment/domain/deliverycompany/service/DeliveryCompanyService.java b/src/main/java/org/c4marathon/assignment/domain/deliverycompany/service/DeliveryCompanyService.java index 81f5efdd5..8df155b32 100644 --- a/src/main/java/org/c4marathon/assignment/domain/deliverycompany/service/DeliveryCompanyService.java +++ b/src/main/java/org/c4marathon/assignment/domain/deliverycompany/service/DeliveryCompanyService.java @@ -54,7 +54,7 @@ private void validateRequest(UpdateDeliveryStatusRequest request, Delivery deliv */ private boolean isInvalidChangeStatus(DeliveryStatus future, DeliveryStatus current) { return future.isPending() - || (future.isDelivering() && !current.isPending()) - || (future.isDelivered() && !current.isDelivering()); + || (future.isDelivering() && !current.isPending()) + || (future.isDelivered() && !current.isDelivering()); } } diff --git a/src/main/java/org/c4marathon/assignment/domain/order/repository/OrderRepository.java b/src/main/java/org/c4marathon/assignment/domain/order/repository/OrderRepository.java index 7a5b78cf3..c9e9852f3 100644 --- a/src/main/java/org/c4marathon/assignment/domain/order/repository/OrderRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/order/repository/OrderRepository.java @@ -1,7 +1,11 @@ package org.c4marathon.assignment.domain.order.repository; +import java.util.List; + import org.c4marathon.assignment.domain.order.entity.Order; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderRepository extends JpaRepository { + + boolean existsByConsumerIdAndIdIn(Long consumerId, List ids); } diff --git a/src/main/java/org/c4marathon/assignment/domain/orderproduct/entity/OrderProduct.java b/src/main/java/org/c4marathon/assignment/domain/orderproduct/entity/OrderProduct.java index d4373a244..3c8e9cd20 100644 --- a/src/main/java/org/c4marathon/assignment/domain/orderproduct/entity/OrderProduct.java +++ b/src/main/java/org/c4marathon/assignment/domain/orderproduct/entity/OrderProduct.java @@ -10,6 +10,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -20,7 +21,12 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "order_product_tbl") +@Table( + name = "order_product_tbl", + indexes = { + @Index(name = "created_at_idx", columnList = "created_at") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class OrderProduct extends BaseEntity { diff --git a/src/main/java/org/c4marathon/assignment/domain/orderproduct/repository/OrderProductRepository.java b/src/main/java/org/c4marathon/assignment/domain/orderproduct/repository/OrderProductRepository.java index 7226a7193..effe55ec8 100644 --- a/src/main/java/org/c4marathon/assignment/domain/orderproduct/repository/OrderProductRepository.java +++ b/src/main/java/org/c4marathon/assignment/domain/orderproduct/repository/OrderProductRepository.java @@ -1,9 +1,11 @@ package org.c4marathon.assignment.domain.orderproduct.repository; +import java.time.LocalDateTime; import java.util.List; import org.c4marathon.assignment.domain.orderproduct.entity.OrderProduct; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,4 +16,16 @@ public interface OrderProductRepository extends JpaRepository findByOrderJoinFetchProductAndSeller(@Param("id") Long orderId); + + @Query(value = "select order_id from order_product_tbl op where op.product_id = :product_id", nativeQuery = true) + List findOrderIdByProductId(@Param("product_id") Long id); + + @Modifying + @Query(value = """ + delete + from order_product_tbl op + where op.created_at <= :dateTime + limit :pageSize + """, nativeQuery = true) + int deleteOrderProductTable(@Param("dateTime") LocalDateTime dateTime, @Param("pageSize") int pageSize); } diff --git a/src/main/java/org/c4marathon/assignment/domain/orderproduct/service/OrderProductReadService.java b/src/main/java/org/c4marathon/assignment/domain/orderproduct/service/OrderProductReadService.java index 7c5a45a21..d0e384c85 100644 --- a/src/main/java/org/c4marathon/assignment/domain/orderproduct/service/OrderProductReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/orderproduct/service/OrderProductReadService.java @@ -2,6 +2,7 @@ import java.util.List; +import org.c4marathon.assignment.domain.order.repository.OrderRepository; import org.c4marathon.assignment.domain.orderproduct.entity.OrderProduct; import org.c4marathon.assignment.domain.orderproduct.repository.OrderProductRepository; import org.springframework.stereotype.Service; @@ -14,6 +15,7 @@ public class OrderProductReadService { private final OrderProductRepository orderProductRepository; + private final OrderRepository orderRepository; /** * Product와 조인한 OrderProduct list를 orderId로 조회 @@ -30,4 +32,13 @@ public List findByOrderJoinFetchProduct(Long orderId) { public List findByOrderJoinFetchProductAndSeller(Long orderId) { return orderProductRepository.findByOrderJoinFetchProductAndSeller(orderId); } + + /** + * consumer id와 product id로 구매 이력 조회 + */ + @Transactional(readOnly = true) + public boolean existsByConsumerIdAndProductId(Long consumerId, Long productId) { + List orderIds = orderProductRepository.findOrderIdByProductId(productId); + return orderRepository.existsByConsumerIdAndIdIn(consumerId, orderIds); + } } diff --git a/src/main/java/org/c4marathon/assignment/domain/product/controller/ProductController.java b/src/main/java/org/c4marathon/assignment/domain/product/controller/ProductController.java new file mode 100644 index 000000000..4d6403d87 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/product/controller/ProductController.java @@ -0,0 +1,27 @@ +package org.c4marathon.assignment.domain.product.controller; + +import org.c4marathon.assignment.domain.product.dto.request.ProductSearchRequest; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchResponse; +import org.c4marathon.assignment.domain.product.service.ProductReadService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/products") +public class ProductController { + + private final ProductReadService productReadService; + + @GetMapping + public ProductSearchResponse searchProduct( + @Valid @ModelAttribute ProductSearchRequest request + ) { + return productReadService.searchProduct(request); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/product/dto/request/ProductSearchRequest.java b/src/main/java/org/c4marathon/assignment/domain/product/dto/request/ProductSearchRequest.java new file mode 100644 index 000000000..38caacb4a --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/product/dto/request/ProductSearchRequest.java @@ -0,0 +1,48 @@ +package org.c4marathon.assignment.domain.product.dto.request; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.c4marathon.assignment.global.constant.SortType; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +public record ProductSearchRequest( + @NotNull @Size(min = 2, message = "keyword length less than 2") String keyword, + @NotNull SortType sortType, + LocalDateTime createdAt, + Long productId, + Long amount, + Long orderCount, + Double score, + int pageSize +) { + + @Builder + public ProductSearchRequest( + String keyword, + SortType sortType, + LocalDateTime createdAt, + Long productId, + Long amount, + Long orderCount, + Double score, + int pageSize + ) { + this.keyword = keyword; + this.sortType = sortType; + this.createdAt = requireNonNullElse(createdAt, LocalDateTime.now()); + this.productId = requireNonNullElse(productId, Long.MIN_VALUE); + this.amount = requireNonNullElse(amount, getDefaultAmount(sortType)); + this.orderCount = requireNonNullElse(orderCount, Long.MAX_VALUE); + this.score = requireNonNullElse(score, Double.MAX_VALUE); + this.pageSize = pageSize; + } + + private Long getDefaultAmount(SortType sortType) { + return sortType == SortType.PRICE_ASC ? Long.MIN_VALUE : Long.MAX_VALUE; + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchEntry.java b/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchEntry.java new file mode 100644 index 000000000..507ca1ec7 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchEntry.java @@ -0,0 +1,15 @@ +package org.c4marathon.assignment.domain.product.dto.response; + +import java.time.LocalDateTime; + +public record ProductSearchEntry( + long id, + String name, + String description, + long amount, + int stock, + LocalDateTime createdAt, + Long orderCount, + Double avgScore +) { +} diff --git a/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchResponse.java b/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchResponse.java new file mode 100644 index 000000000..eb45fc27d --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/product/dto/response/ProductSearchResponse.java @@ -0,0 +1,8 @@ +package org.c4marathon.assignment.domain.product.dto.response; + +import java.util.List; + +public record ProductSearchResponse( + List productSearchEntries +) { +} diff --git a/src/main/java/org/c4marathon/assignment/domain/product/entity/Product.java b/src/main/java/org/c4marathon/assignment/domain/product/entity/Product.java index 55eacd536..894433014 100644 --- a/src/main/java/org/c4marathon/assignment/domain/product/entity/Product.java +++ b/src/main/java/org/c4marathon/assignment/domain/product/entity/Product.java @@ -2,6 +2,8 @@ import static org.c4marathon.assignment.global.constant.ProductStatus.*; +import java.math.BigDecimal; + import org.c4marathon.assignment.domain.base.entity.BaseEntity; import org.c4marathon.assignment.domain.seller.entity.Seller; import org.c4marathon.assignment.global.constant.ProductStatus; @@ -28,7 +30,12 @@ @Table( name = "product_tbl", indexes = { - @Index(name = "product_name_seller_id_idx", columnList = "name,seller_id") + @Index(name = "product_name_seller_id_idx", columnList = "name,seller_id"), + @Index(name = "amount_product_id_idx", columnList = "amount, product_id"), + @Index(name = "amount_desc_product_id_idx", columnList = "amount desc, product_id"), + @Index(name = "created_at_product_id_idx", columnList = "created_at desc, product_id"), + @Index(name = "avg_score_desc_product_id_idx", columnList = "avg_score desc, product_id"), + @Index(name = "order_count_desc_product_id_idx", columnList = "order_count desc, product_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -64,6 +71,14 @@ public class Product extends BaseEntity { @Column(name = "status", columnDefinition = "VARCHAR(20)") private ProductStatus productStatus; + @NotNull + @Column(name = "avg_score", columnDefinition = "DECIMAL(5, 4) DEFAULT 0.0000") + private BigDecimal avgScore; + + @NotNull + @Column(name = "order_count", columnDefinition = "BIGINT DEFAULT 0") + private Long orderCount; + @Builder public Product( String name, @@ -78,6 +93,8 @@ public Product( this.stock = stock; this.seller = seller; this.productStatus = IN_STOCK; + this.orderCount = 0L; + this.avgScore = BigDecimal.ZERO; } public void decreaseStock(Integer quantity) { @@ -90,4 +107,12 @@ public void decreaseStock(Integer quantity) { public boolean isSoldOut() { return this.productStatus == OUT_OF_STOCK; } + + public void increaseOrderCount() { + this.orderCount++; + } + + public void updateAvgScore(BigDecimal avgScore) { + this.avgScore = avgScore; + } } diff --git a/src/main/java/org/c4marathon/assignment/domain/product/repository/ProductMapper.java b/src/main/java/org/c4marathon/assignment/domain/product/repository/ProductMapper.java new file mode 100644 index 000000000..c337d6154 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/product/repository/ProductMapper.java @@ -0,0 +1,12 @@ +package org.c4marathon.assignment.domain.product.repository; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.c4marathon.assignment.domain.product.dto.request.ProductSearchRequest; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchEntry; + +@Mapper +public interface ProductMapper { + List selectByCondition(ProductSearchRequest request); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/product/service/ProductReadService.java b/src/main/java/org/c4marathon/assignment/domain/product/service/ProductReadService.java index 7a8992f8c..d15a718b6 100644 --- a/src/main/java/org/c4marathon/assignment/domain/product/service/ProductReadService.java +++ b/src/main/java/org/c4marathon/assignment/domain/product/service/ProductReadService.java @@ -1,6 +1,9 @@ package org.c4marathon.assignment.domain.product.service; +import org.c4marathon.assignment.domain.product.dto.request.ProductSearchRequest; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchResponse; import org.c4marathon.assignment.domain.product.entity.Product; +import org.c4marathon.assignment.domain.product.repository.ProductMapper; import org.c4marathon.assignment.domain.product.repository.ProductRepository; import org.c4marathon.assignment.domain.seller.entity.Seller; import org.c4marathon.assignment.global.error.ErrorCode; @@ -14,6 +17,7 @@ public class ProductReadService { private final ProductRepository productRepository; + private final ProductMapper productMapper; /** * Seller와 product.name으로 Product 존재 여부 확인 @@ -31,4 +35,17 @@ public Product findById(Long id) { return productRepository.findByIdJoinFetch(id) .orElseThrow(() -> ErrorCode.PRODUCT_NOT_FOUND.baseException("id: %d", id)); } + + /** + * sortType에 해당하는 조건으로 pagination + * Newest: 최신순, created_at desc, product_id asc + * PriceAsc: 가격 낮은 순, amount asc, product_id asc + * PriceDesc: 가격 높은 순, amount desc, product_id asc + * Popularity: 인기 순(주문 많은 순), order_count desc, product_id asc + * TopRated: 평점 높은 순(review score), avg_score desc, product_id asc + */ + @Transactional(readOnly = true) + public ProductSearchResponse searchProduct(ProductSearchRequest request) { + return new ProductSearchResponse(productMapper.selectByCondition(request)); + } } diff --git a/src/main/java/org/c4marathon/assignment/domain/review/controller/ReviewController.java b/src/main/java/org/c4marathon/assignment/domain/review/controller/ReviewController.java new file mode 100644 index 000000000..f411decdf --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/controller/ReviewController.java @@ -0,0 +1,27 @@ +package org.c4marathon.assignment.domain.review.controller; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; +import org.c4marathon.assignment.domain.review.service.ReviewService; +import org.c4marathon.assignment.global.auth.ConsumerThreadLocal; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/reviews") +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + public void createReview(@Valid @RequestBody ReviewCreateRequest request) { + Consumer consumer = ConsumerThreadLocal.get(); + reviewService.createReview(consumer, request); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/review/dto/request/ReviewCreateRequest.java b/src/main/java/org/c4marathon/assignment/domain/review/dto/request/ReviewCreateRequest.java new file mode 100644 index 000000000..d982a1f43 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/dto/request/ReviewCreateRequest.java @@ -0,0 +1,14 @@ +package org.c4marathon.assignment.domain.review.dto.request; + +import org.hibernate.validator.constraints.Range; + +import jakarta.validation.constraints.Size; + +public record ReviewCreateRequest( + @Range(min = 1, max = 5, message = "invalid score range") + int score, + @Size(max = 100, message = "comment length more than 100") + String comment, + long productId +) { +} diff --git a/src/main/java/org/c4marathon/assignment/domain/review/entity/Review.java b/src/main/java/org/c4marathon/assignment/domain/review/entity/Review.java new file mode 100644 index 000000000..3dbc4f328 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/entity/Review.java @@ -0,0 +1,61 @@ +package org.c4marathon.assignment.domain.review.entity; + +import org.c4marathon.assignment.domain.base.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "review_tbl", + indexes = { + @Index(name = "consumer_id_product_id_idx", columnList = "consumer_id, product_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "review_id", columnDefinition = "BIGINT") + private Long id; + + @NotNull + @Column(name = "consumer_id", columnDefinition = "BIGINT") + private Long consumerId; + + @NotNull + @Column(name = "product_id", columnDefinition = "BIGINT") + private Long productId; + + @NotNull + @Column(name = "score", columnDefinition = "INTEGER default 3") + private int score; + + @Column(name = "comment", columnDefinition = "VARCHAR(100)") + private String comment; + + @Builder + public Review( + Long consumerId, + Long productId, + int score, + String comment + ) { + this.consumerId = consumerId; + this.productId = productId; + this.score = score; + this.comment = comment; + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/review/entity/ReviewFactory.java b/src/main/java/org/c4marathon/assignment/domain/review/entity/ReviewFactory.java new file mode 100644 index 000000000..db803c618 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/entity/ReviewFactory.java @@ -0,0 +1,19 @@ +package org.c4marathon.assignment.domain.review.entity; + +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewFactory { + + public static Review buildReview(Long consumerId, ReviewCreateRequest request) { + return Review.builder() + .consumerId(consumerId) + .productId(request.productId()) + .score(request.score()) + .comment(request.comment()) + .build(); + } +} diff --git a/src/main/java/org/c4marathon/assignment/domain/review/repository/ReviewRepository.java b/src/main/java/org/c4marathon/assignment/domain/review/repository/ReviewRepository.java new file mode 100644 index 000000000..67917dc58 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/repository/ReviewRepository.java @@ -0,0 +1,11 @@ +package org.c4marathon.assignment.domain.review.repository; + +import org.c4marathon.assignment.domain.review.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + + boolean existsByConsumerIdAndProductId(Long consumerId, Long productId); + + Long countByProductId(Long productId); +} diff --git a/src/main/java/org/c4marathon/assignment/domain/review/service/ReviewService.java b/src/main/java/org/c4marathon/assignment/domain/review/service/ReviewService.java new file mode 100644 index 000000000..8f584babd --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/domain/review/service/ReviewService.java @@ -0,0 +1,56 @@ +package org.c4marathon.assignment.domain.review.service; + +import static org.c4marathon.assignment.domain.review.entity.ReviewFactory.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.c4marathon.assignment.domain.orderproduct.service.OrderProductReadService; +import org.c4marathon.assignment.domain.product.entity.Product; +import org.c4marathon.assignment.domain.product.service.ProductReadService; +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; +import org.c4marathon.assignment.domain.review.repository.ReviewRepository; +import org.c4marathon.assignment.global.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ProductReadService productReadService; + private final OrderProductReadService orderProductReadService; + + /** + * score, comment를 입력받아 Review entity 생성 + * product avgScore update + * @throws org.c4marathon.assignment.global.error.BaseException + * productId에 해당하는 구매 이력이 존재하지 않는 경우, 또는 리뷰 작성 가능 기간(30일)이 지난 경우 + * -> 구매 이력은 구매 이후 30일이 지난 이후에 삭제되기 때문에 구매 이력이 없다면 아예 구매한 적이 없거나, 리뷰 작성 가능 기간이 지난 것을 의미함 + * 이미 해당 product에 대한 리뷰를 작성한 경우 + */ + @Transactional + public void createReview(Consumer consumer, ReviewCreateRequest request) { + if (!orderProductReadService.existsByConsumerIdAndProductId(consumer.getId(), request.productId())) { + throw ErrorCode.NOT_POSSIBLE_CREATE_REVIEW.baseException(); + } + if (reviewRepository.existsByConsumerIdAndProductId(consumer.getId(), request.productId())) { + throw ErrorCode.REVIEW_ALREADY_EXISTS.baseException(); + } + + Product product = productReadService.findById(request.productId()); + Long reviewCount = reviewRepository.countByProductId(request.productId()); + product.updateAvgScore(calculateAvgScore(product.getAvgScore(), reviewCount, request.score())); + reviewRepository.save(buildReview(consumer.getId(), request)); + } + + private BigDecimal calculateAvgScore(BigDecimal avgScore, Long reviewCount, int score) { + return avgScore.multiply(BigDecimal.valueOf(reviewCount)) + .add(BigDecimal.valueOf(score)) + .divide(BigDecimal.valueOf(reviewCount + 1), 4, RoundingMode.DOWN); + } +} diff --git a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java index cdfb897d7..36994cfff 100644 --- a/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java +++ b/src/main/java/org/c4marathon/assignment/global/configuration/WebConfiguration.java @@ -21,7 +21,7 @@ public class WebConfiguration implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry .addInterceptor(consumerInterceptor) - .addPathPatterns("/consumers/**"); + .addPathPatterns("/consumers/**", "/reviews/**"); registry .addInterceptor(sellerInterceptor) diff --git a/src/main/java/org/c4marathon/assignment/global/constant/SortType.java b/src/main/java/org/c4marathon/assignment/global/constant/SortType.java new file mode 100644 index 000000000..bd516c4ef --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/constant/SortType.java @@ -0,0 +1,10 @@ +package org.c4marathon.assignment.global.constant; + +public enum SortType { + + POPULARITY, + TOP_RATED, + PRICE_ASC, + PRICE_DESC, + NEWEST; +} diff --git a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java index 899511186..2e009f68e 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java +++ b/src/main/java/org/c4marathon/assignment/global/error/ErrorCode.java @@ -29,7 +29,9 @@ public enum ErrorCode { CONFIRM_NOT_AVAILABLE(BAD_REQUEST, "구매 확정이 불가능한 상태입니다."), NOT_ENOUGH_PRODUCT_STOCK(BAD_REQUEST, "상품의 재고가 부족합니다."), NOT_ENOUGH_POINT(BAD_REQUEST, "사용할 포인트가 부족합니다."), - CONSUMER_NOT_FOUND_BY_ID(NOT_FOUND, "id에 해당하는 Consumer가 존재하지 않습니다."); + CONSUMER_NOT_FOUND_BY_ID(NOT_FOUND, "id에 해당하는 Consumer가 존재하지 않습니다."), + REVIEW_ALREADY_EXISTS(CONFLICT, "해당 product에 대한 review가 이미 존재합니다."), + NOT_POSSIBLE_CREATE_REVIEW(NOT_FOUND, "해당 product에 대한 구매 이력이 존재하지 않거나, 리뷰 작성 가능 기간이 지났습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/org/c4marathon/assignment/global/error/GlobalExceptionHandler.java b/src/main/java/org/c4marathon/assignment/global/error/GlobalExceptionHandler.java index fc3937e94..227b2b985 100644 --- a/src/main/java/org/c4marathon/assignment/global/error/GlobalExceptionHandler.java +++ b/src/main/java/org/c4marathon/assignment/global/error/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @RestControllerAdvice @@ -35,6 +36,14 @@ public ResponseEntity handleBindException(BindingResult r ); } + @ExceptionHandler(value = ConstraintViolationException.class) + public ResponseEntity constraintViolationException() { + return new ResponseEntity<>( + new ExceptionResponse(BIND_ERROR.name(), BIND_ERROR.getMessage()), + HttpStatus.BAD_REQUEST + ); + } + private record ExceptionResponse( String errorCode, String message diff --git a/src/main/java/org/c4marathon/assignment/global/scheduler/OrderProductScheduler.java b/src/main/java/org/c4marathon/assignment/global/scheduler/OrderProductScheduler.java new file mode 100644 index 000000000..bfc904953 --- /dev/null +++ b/src/main/java/org/c4marathon/assignment/global/scheduler/OrderProductScheduler.java @@ -0,0 +1,33 @@ +package org.c4marathon.assignment.global.scheduler; + +import java.time.LocalDateTime; + +import org.c4marathon.assignment.domain.orderproduct.repository.OrderProductRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OrderProductScheduler { + + private static final int PAGINATION_SIZE = 1000; + private static final int EXISTS_DAY = 30; + + private final OrderProductRepository orderProductRepository; + + /** + * 24시간 마다 수행되는 스케줄러로, 30일 이전의 주문 내역을 삭제 + */ + @Scheduled(cron = "0 0 0 * * ?") + @Transactional + public void scheduleDeleteOrderProducts() { + int deletedCount; + do { + LocalDateTime dateTime = LocalDateTime.now().minusDays(EXISTS_DAY); + deletedCount = orderProductRepository.deleteOrderProductTable(dateTime, PAGINATION_SIZE); + } while (deletedCount != 0); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6a899a453..262c9dfaf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,7 +5,7 @@ spring: format_sql: true show-sql: true hibernate: - ddl-auto: create-drop + ddl-auto: validate database: mysql datasource: @@ -18,6 +18,9 @@ spring: maximum-pool-size: 10 url: jdbc:mysql://localhost:3306/community?rewriteBatchedStatements=true +mybatis: + mapper-locations: classpath:mapper/*.xml + logging: level: org.c4marathon.assignment.global.error: debug \ No newline at end of file diff --git a/src/main/resources/mapper/ProductMapper.xml b/src/main/resources/mapper/ProductMapper.xml new file mode 100644 index 000000000..f5ed1a4a8 --- /dev/null +++ b/src/main/resources/mapper/ProductMapper.xml @@ -0,0 +1,45 @@ + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/c4marathon/assignment/domain/controller/ControllerTestSupport.java b/src/test/java/org/c4marathon/assignment/domain/controller/ControllerTestSupport.java index 9cf9872b2..417606875 100644 --- a/src/test/java/org/c4marathon/assignment/domain/controller/ControllerTestSupport.java +++ b/src/test/java/org/c4marathon/assignment/domain/controller/ControllerTestSupport.java @@ -11,6 +11,10 @@ import org.c4marathon.assignment.domain.deliverycompany.service.DeliveryCompanyService; import org.c4marathon.assignment.domain.pay.controller.PayController; import org.c4marathon.assignment.domain.pay.service.PayService; +import org.c4marathon.assignment.domain.product.controller.ProductController; +import org.c4marathon.assignment.domain.product.service.ProductReadService; +import org.c4marathon.assignment.domain.review.controller.ReviewController; +import org.c4marathon.assignment.domain.review.service.ReviewService; import org.c4marathon.assignment.domain.seller.controller.SellerController; import org.c4marathon.assignment.domain.seller.service.SellerService; import org.c4marathon.assignment.global.interceptor.ConsumerInterceptor; @@ -30,7 +34,7 @@ import jakarta.servlet.http.HttpServletResponse; @WebMvcTest(controllers = {AuthController.class, ConsumerController.class, DeliveryCompanyController.class, - PayController.class, SellerController.class}) + PayController.class, SellerController.class, ProductController.class, ReviewController.class}) @MockBean(JpaMetamodelMappingContext.class) public abstract class ControllerTestSupport { @@ -65,4 +69,8 @@ void setUp(WebApplicationContext webApplicationContext) { protected PayService payService; @MockBean protected AuthService authService; + @MockBean + protected ProductReadService productReadService; + @MockBean + protected ReviewService reviewService; } diff --git a/src/test/java/org/c4marathon/assignment/domain/controller/product/ProductControllerTest.java b/src/test/java/org/c4marathon/assignment/domain/controller/product/ProductControllerTest.java new file mode 100644 index 000000000..0531e0cc7 --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/controller/product/ProductControllerTest.java @@ -0,0 +1,83 @@ +package org.c4marathon.assignment.domain.controller.product; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.c4marathon.assignment.domain.controller.ControllerTestSupport; +import org.c4marathon.assignment.domain.product.dto.request.ProductSearchRequest; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchEntry; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchResponse; +import org.c4marathon.assignment.global.constant.SortType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +public class ProductControllerTest extends ControllerTestSupport { + + @DisplayName("상품 검색 시") + @Nested + class SearchProduct { + + private static final String REQUEST_URL = "/products"; + + @BeforeEach + void setUp() { + given(productReadService.searchProduct(any(ProductSearchRequest.class))) + .willReturn(new ProductSearchResponse(List.of(new ProductSearchEntry(1, "name", "des", 1, 1, + LocalDateTime.now(), 0L, 0.0)))); + } + + @DisplayName("올바른 파라미터를 요청하면 성공한다.") + @Test + void success_when_validRequest() throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("keyword", "ab") + .param("sortType", SortType.NEWEST.name()) + .param("pageSize", "1")) + .andExpectAll( + status().isOk(), + jsonPath("$.productSearchEntries[0].id").value(1), + jsonPath("$.productSearchEntries[0].name").value("name"), + jsonPath("$.productSearchEntries[0].description").value("des"), + jsonPath("$.productSearchEntries[0].amount").value(1), + jsonPath("$.productSearchEntries[0].stock").value(1) + ); + } + + @DisplayName("keyword가 null이거나 길이가 2보다 작으면 실패한다.") + @ParameterizedTest + @ValueSource(strings = {"a", "b"}) + @NullAndEmptySource + void fail_when_keywordIsNullOrLessThanTwo(String keyword) throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("keyword", keyword) + .param("sortType", SortType.NEWEST.name()) + .param("pageSize", "1")) + .andExpectAll( + status().isBadRequest() + ); + } + + @DisplayName("sortType이 null이거나 존재하지 않은 값이면 실패한다.") + @ParameterizedTest + @ValueSource(strings = {"a", "b"}) + @NullAndEmptySource + void fail_when_sortTypeIsNullOrInvalid(String sortType) throws Exception { + mockMvc.perform(get(REQUEST_URL) + .param("keyword", "ab") + .param("sortType", sortType) + .param("pageSize", "1")) + .andExpectAll( + status().isBadRequest() + ); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/controller/review/ReviewControllerTest.java b/src/test/java/org/c4marathon/assignment/domain/controller/review/ReviewControllerTest.java new file mode 100644 index 000000000..4074b17e9 --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/controller/review/ReviewControllerTest.java @@ -0,0 +1,68 @@ +package org.c4marathon.assignment.domain.controller.review; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.nio.charset.StandardCharsets; + +import org.c4marathon.assignment.domain.controller.ControllerTestSupport; +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; + +public class ReviewControllerTest extends ControllerTestSupport { + + @DisplayName("리뷰 작성 시") + @Nested + class CreateReview { + + private static final String REQUEST_URL = "/reviews"; + + @DisplayName("올바른 요청을 하면 성공한다.") + @Test + void success_when_validRequest() throws Exception { + ReviewCreateRequest request = new ReviewCreateRequest(1, "comment", 100); + + mockMvc.perform(post(REQUEST_URL) + .content(om.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andExpectAll( + status().isOk() + ); + } + + @DisplayName("score가 올바른 범위가 아니면 실패한다.") + @ParameterizedTest + @ValueSource(ints = {0, -1, 6, 7}) + void fail_when_invalidScoreRange(int score) throws Exception { + ReviewCreateRequest request = new ReviewCreateRequest(score, "comment", 100); + + mockMvc.perform(post(REQUEST_URL) + .content(om.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andExpectAll( + status().isBadRequest() + ); + } + + @DisplayName("comment의 크기가 100이 넘어가면 실패한다.") + @Test + void fail_when_commentLengthGreaterThan100() throws Exception { + ReviewCreateRequest request = new ReviewCreateRequest(1, "a".repeat(101), 100); + + mockMvc.perform(post(REQUEST_URL) + .content(om.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8)) + .andExpectAll( + status().isBadRequest() + ); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/entity/ConsumerTest.java b/src/test/java/org/c4marathon/assignment/domain/entity/ConsumerTest.java new file mode 100644 index 000000000..053628ead --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/entity/ConsumerTest.java @@ -0,0 +1,35 @@ +package org.c4marathon.assignment.domain.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.c4marathon.assignment.domain.consumer.entity.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class ConsumerTest { + + @DisplayName("Consumer Entity test") + @Nested + class ConsumerEntityTest { + + private Consumer consumer; + + @BeforeEach + void setUp() { + consumer = new Consumer("email", "address"); + } + + @DisplayName("consumer entity의 메서드 수행 시, 필드가 변경된다.") + @Test + void updateField_when_invokeEntityMethod() { + consumer.addBalance(100L); + assertThat(consumer.getBalance()).isEqualTo(100L); + consumer.decreaseBalance(100L); + assertThat(consumer.getBalance()).isZero(); + consumer.updatePoint(100L); + assertThat(consumer.getPoint()).isEqualTo(100L); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/entity/OrderTest.java b/src/test/java/org/c4marathon/assignment/domain/entity/OrderTest.java new file mode 100644 index 000000000..389e8249d --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/entity/OrderTest.java @@ -0,0 +1,44 @@ +package org.c4marathon.assignment.domain.entity; + +import static org.assertj.core.api.Assertions.*; +import static org.c4marathon.assignment.global.constant.OrderStatus.*; + +import org.c4marathon.assignment.domain.order.entity.Order; +import org.c4marathon.assignment.global.constant.OrderStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class OrderTest { + + @DisplayName("Order Entity test") + @Nested + class OrderEntityTest { + + private Order order; + + @BeforeEach + void setUp() { + order = Order.builder() + .orderStatus(OrderStatus.COMPLETE_PAYMENT) + .consumer(null) + .usedPoint(0) + .build(); + } + + @DisplayName("order entity의 메서드 수행 시, 필드가 변경된다.") + @Test + void updateField_when_invokeOrderEntityMethod() { + order.updateOrderStatus(CONFIRM); + order.updateDeliveryId(1L); + order.updateEarnedPoint(10); + order.updateTotalAmount(1000); + + assertThat(order.getOrderStatus()).isEqualTo(CONFIRM); + assertThat(order.getDeliveryId()).isEqualTo(1L); + assertThat(order.getEarnedPoint()).isEqualTo(10); + assertThat(order.getTotalAmount()).isEqualTo(1000); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/entity/ProductTest.java b/src/test/java/org/c4marathon/assignment/domain/entity/ProductTest.java new file mode 100644 index 000000000..fe95be77e --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/entity/ProductTest.java @@ -0,0 +1,53 @@ +package org.c4marathon.assignment.domain.entity; + +import static org.assertj.core.api.Assertions.*; +import static org.c4marathon.assignment.global.constant.ProductStatus.*; + +import java.math.BigDecimal; + +import org.c4marathon.assignment.domain.product.entity.Product; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class ProductTest { + + @DisplayName("Product Entity test") + @Nested + class ProductEntityTest { + + private Product product; + + @BeforeEach + void setUp() { + product = Product.builder() + .name("name") + .description("des") + .amount(100L) + .stock(100) + .seller(null) + .build(); + } + + @DisplayName("consumer entity의 메서드 수행 시, 필드가 변경된다.") + @Test + void updateField_when_invokeEntityMethod() { + assertThat(product.getName()).isEqualTo("name"); + assertThat(product.getDescription()).isEqualTo("des"); + assertThat(product.getAmount()).isEqualTo(100L); + assertThat(product.getStock()).isEqualTo(100); + assertThat(product.getProductStatus()).isEqualTo(IN_STOCK); + assertThat(product.getOrderCount()).isZero(); + assertThat(product.getAvgScore()).isZero(); + + product.decreaseStock(100); + assertThat(product.getProductStatus()).isEqualTo(OUT_OF_STOCK); + assertThat(product.isSoldOut()).isTrue(); + product.increaseOrderCount(); + assertThat(product.getOrderCount()).isEqualTo(1); + product.updateAvgScore(BigDecimal.ONE); + assertThat(product.getAvgScore()).isEqualTo(BigDecimal.ONE); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/service/ServiceTestSupport.java b/src/test/java/org/c4marathon/assignment/domain/service/ServiceTestSupport.java index 73217ef8c..145ac4069 100644 --- a/src/test/java/org/c4marathon/assignment/domain/service/ServiceTestSupport.java +++ b/src/test/java/org/c4marathon/assignment/domain/service/ServiceTestSupport.java @@ -14,7 +14,9 @@ import org.c4marathon.assignment.domain.pay.repository.PayRepository; import org.c4marathon.assignment.domain.pointlog.repository.PointLogRepository; import org.c4marathon.assignment.domain.product.entity.Product; +import org.c4marathon.assignment.domain.product.repository.ProductMapper; import org.c4marathon.assignment.domain.product.repository.ProductRepository; +import org.c4marathon.assignment.domain.review.repository.ReviewRepository; import org.c4marathon.assignment.domain.seller.entity.Seller; import org.c4marathon.assignment.domain.seller.repository.SellerRepository; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +44,10 @@ public abstract class ServiceTestSupport { protected PayRepository payRepository; @Mock protected PointLogRepository pointLogRepository; + @Mock + protected ReviewRepository reviewRepository; + @Mock + protected ProductMapper productMapper; @Mock protected Order order; diff --git a/src/test/java/org/c4marathon/assignment/domain/service/product/ProductReadServiceTest.java b/src/test/java/org/c4marathon/assignment/domain/service/product/ProductReadServiceTest.java index a18071b96..6d3f509cf 100644 --- a/src/test/java/org/c4marathon/assignment/domain/service/product/ProductReadServiceTest.java +++ b/src/test/java/org/c4marathon/assignment/domain/service/product/ProductReadServiceTest.java @@ -1,19 +1,27 @@ package org.c4marathon.assignment.domain.service.product; +import static java.util.Comparator.*; import static org.assertj.core.api.Assertions.*; import static org.c4marathon.assignment.global.error.ErrorCode.*; import static org.mockito.BDDMockito.*; +import java.util.List; import java.util.Optional; +import org.c4marathon.assignment.domain.product.dto.request.ProductSearchRequest; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchEntry; +import org.c4marathon.assignment.domain.product.dto.response.ProductSearchResponse; import org.c4marathon.assignment.domain.product.service.ProductReadService; import org.c4marathon.assignment.domain.seller.entity.Seller; import org.c4marathon.assignment.domain.service.ServiceTestSupport; +import org.c4marathon.assignment.global.constant.SortType; import org.c4marathon.assignment.global.error.BaseException; import org.c4marathon.assignment.global.error.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.InjectMocks; public class ProductReadServiceTest extends ServiceTestSupport { @@ -65,4 +73,30 @@ void throwException_when_notExists() { .hasMessage(exception.getMessage()); } } + + @DisplayName("상품 검색 시") + @Nested + class SearchProduct { + + @DisplayName("정렬된 순서로 조회된다.") + @ParameterizedTest + @EnumSource(value = SortType.class) + void returnSortedList_when_searchProduct(SortType sortType) { + ProductSearchRequest request = new ProductSearchRequest("ab", sortType, null, null, null, null, null, 100); + ProductSearchResponse response = productReadService.searchProduct(request); + List result = response.productSearchEntries(); + + switch (sortType) { + case TOP_RATED -> + assertThat(result).isSortedAccordingTo(comparing(ProductSearchEntry::avgScore).reversed()); + case NEWEST -> + assertThat(result).isSortedAccordingTo(comparing(ProductSearchEntry::createdAt).reversed()); + case PRICE_ASC -> assertThat(result).isSortedAccordingTo(comparing(ProductSearchEntry::amount)); + case PRICE_DESC -> + assertThat(result).isSortedAccordingTo(comparing(ProductSearchEntry::amount).reversed()); + case POPULARITY -> + assertThat(result).isSortedAccordingTo(comparing(ProductSearchEntry::orderCount).reversed()); + } + } + } } diff --git a/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewFactoryTest.java b/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewFactoryTest.java new file mode 100644 index 000000000..faa10f954 --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewFactoryTest.java @@ -0,0 +1,33 @@ +package org.c4marathon.assignment.domain.service.review; + +import static org.assertj.core.api.Assertions.*; +import static org.c4marathon.assignment.domain.review.entity.ReviewFactory.*; + +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; +import org.c4marathon.assignment.domain.review.entity.Review; +import org.c4marathon.assignment.domain.review.entity.ReviewFactory; +import org.c4marathon.assignment.domain.service.ServiceTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +public class ReviewFactoryTest extends ServiceTestSupport { + + @InjectMocks + private ReviewFactory reviewFactory; + + @DisplayName("review entity 생성 시") + @Nested + class BuildReview { + + @Test + @DisplayName("review entity 생성 시 request에 맞는 필드가 주입된다.") + void createReviewEntity() { + Review review = buildReview(1L, new ReviewCreateRequest(3, "comment", 1)); + assertThat(review.getProductId()).isEqualTo(1L); + assertThat(review.getComment()).isEqualTo("comment"); + assertThat(review.getScore()).isEqualTo(3); + } + } +} diff --git a/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewServiceTest.java b/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewServiceTest.java new file mode 100644 index 000000000..15389e205 --- /dev/null +++ b/src/test/java/org/c4marathon/assignment/domain/service/review/ReviewServiceTest.java @@ -0,0 +1,83 @@ +package org.c4marathon.assignment.domain.service.review; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.math.BigDecimal; + +import org.c4marathon.assignment.domain.orderproduct.service.OrderProductReadService; +import org.c4marathon.assignment.domain.product.service.ProductReadService; +import org.c4marathon.assignment.domain.review.dto.request.ReviewCreateRequest; +import org.c4marathon.assignment.domain.review.service.ReviewService; +import org.c4marathon.assignment.domain.service.ServiceTestSupport; +import org.c4marathon.assignment.global.error.BaseException; +import org.c4marathon.assignment.global.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +public class ReviewServiceTest extends ServiceTestSupport { + + @InjectMocks + private ReviewService reviewService; + @Mock + private ProductReadService productReadService; + @Mock + private OrderProductReadService orderProductReadService; + + @DisplayName("리뷰 작성 시") + @Nested + class CreateReview { + + @DisplayName("처음 리뷰를 작성하는 것이라면, review entity가 생성되고 product의 avgScore가 수정된다.") + @Test + void updateAvgScore_when_createReview() { + ReviewCreateRequest request = new ReviewCreateRequest(3, "comment", 1); + + given(productReadService.findById(anyLong())).willReturn(product); + given(product.getAvgScore()).willReturn(BigDecimal.ZERO); + given(reviewRepository.countByProductId(anyLong())).willReturn(0L); + given(orderProductReadService.existsByConsumerIdAndProductId(anyLong(), anyLong())).willReturn(true); + + reviewService.createReview(consumer, request); + + then(product) + .should(times(1)) + .updateAvgScore(any(BigDecimal.class)); + then(reviewRepository) + .should(times(1)) + .save(any()); + } + + @DisplayName("중복 리뷰는 불가능하다.") + @Test + void fail_when_duplicateReview() { + ReviewCreateRequest request = new ReviewCreateRequest(3, "comment", 1); + + given(orderProductReadService.existsByConsumerIdAndProductId(anyLong(), anyLong())).willReturn(true); + given(reviewRepository.existsByConsumerIdAndProductId(anyLong(), anyLong())).willReturn(true); + + ErrorCode errorCode = ErrorCode.REVIEW_ALREADY_EXISTS; + BaseException exception = new BaseException(errorCode.name(), errorCode.getMessage()); + assertThatThrownBy(() -> reviewService.createReview(consumer, request)) + .isInstanceOf(exception.getClass()) + .hasMessageMatching(exception.getMessage()); + } + + @DisplayName("구매 이력이 존재하지 않으면, 리뷰는 불가능하다.") + @Test + void fail_when_notExistsOrderProduct() { + ReviewCreateRequest request = new ReviewCreateRequest(3, "comment", 1); + + given(orderProductReadService.existsByConsumerIdAndProductId(anyLong(), anyLong())).willReturn(false); + + ErrorCode errorCode = ErrorCode.NOT_POSSIBLE_CREATE_REVIEW; + BaseException exception = new BaseException(errorCode.name(), errorCode.getMessage()); + assertThatThrownBy(() -> reviewService.createReview(consumer, request)) + .isInstanceOf(exception.getClass()) + .hasMessageMatching(exception.getMessage()); + } + } +}