diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java index 6667d93..3e64ba3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopService.java @@ -10,7 +10,7 @@ public interface ShopService { List getActiveProducts(Long memberId, Boolean isBundle); - void requestPurchase(Long memberId, Long productId); + void requestPurchase(Long memberId, Long productId, int quantity); PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId); diff --git a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java index ddceaad..8b97468 100644 --- a/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/product/service/ShopServiceImpl.java @@ -67,7 +67,7 @@ public List getActiveProducts(Long memberId, Boolean isBundle) @Override @DistributedLock(key = "order:pending", identifier = "#memberId") - public void requestPurchase(Long memberId, Long productId) { + public void requestPurchase(Long memberId, Long productId, int quantity) { Product product = productRepository.findById(productId) .orElseThrow(() -> new BusinessException(ItemErrorCode.PRODUCT_NOT_FOUND)); @@ -81,7 +81,7 @@ public void requestPurchase(Long memberId, Long productId) { throw new BusinessException(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); } - validatePurchaseLimit(memberId, product, now); + validatePurchaseLimit(memberId, product, now, quantity); validatePurchaseCountLimit(memberId, product, now); OrdererInfoDto ordererInfo = userOrderClient.getOrdererInfo(memberId); @@ -95,8 +95,8 @@ public void requestPurchase(Long memberId, Long productId) { .requestedItemName(product.getName()) .requesterRealName(realName) .requesterUsername(username) - .requestedPrice(product.getPrice()) - .expectedPrice(product.getPrice()) + .requestedPrice(product.getPrice() * quantity) + .expectedPrice(product.getPrice() * quantity) .requestedAt(now) .expiresAt(now.plusMinutes(paymentOrderProperties.expireMinutes())) .build(); @@ -104,7 +104,7 @@ public void requestPurchase(Long memberId, Long productId) { product.getRewards().forEach(reward -> order.addOrderItem( OrderItem.builder() .itemType(reward.getItemType()) - .quantity(reward.getQuantity()) + .quantity(reward.getQuantity() * quantity) .build() )); @@ -200,11 +200,11 @@ public PurchaseLimitResponse getMyPurchaseLimits(Long memberId) { ); } - private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now) { + private void validatePurchaseLimit(Long memberId, Product product, LocalDateTime now, int quantity) { Map requestedQuantityByType = getRequestedQuantityByType(product); for (ItemType itemType : LIMITED_ITEM_TYPES) { - int requestedQuantity = requestedQuantityByType.getOrDefault(itemType, 0); + int requestedQuantity = requestedQuantityByType.getOrDefault(itemType, 0) * quantity; if (requestedQuantity == 0) { continue; } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java index 63bfa22..e717d9d 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/ShopController.java @@ -2,6 +2,7 @@ import java.util.List; +import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -54,9 +55,10 @@ public ResponseEntity> getMyPurchaseLimits( @PostMapping("/purchase/{productId}") public ResponseEntity> requestPurchase( @CurrentMember MemberInfo memberInfo, - @PathVariable Long productId + @PathVariable Long productId, + @RequestParam(defaultValue = "1") @Min(1) int quantity ) { - shopService.requestPurchase(memberInfo.memberId(), productId); + shopService.requestPurchase(memberInfo.memberId(), productId, quantity); return ResponseEntity.ok(ApiResponse.ok()); } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java similarity index 97% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java index c39d907..6245bb0 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminRouletteController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import java.util.List; diff --git a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java index 40a8920..6f44cbe 100644 --- a/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/product/service/ShopServiceImplTest.java @@ -67,6 +67,8 @@ class ShopServiceImplTest { @Mock private PaymentOrderProperties paymentOrderProperties; + private static final int DEFAULT_QUANTITY = 1; + @Test @DisplayName("상품 ID 기반 요청이면 상품 가격/구성품으로 주문을 생성한다") void shouldCreateOrderFromProductSnapshot() { @@ -84,7 +86,7 @@ void shouldCreateOrderFromProductSnapshot() { // when LocalDateTime before = LocalDateTime.now(); - shopService.requestPurchase(100L, 3L); + shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY); LocalDateTime after = LocalDateTime.now(); // then @@ -156,7 +158,7 @@ void shouldThrowWhenProductNotFound() { given(productRepository.findById(999L)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 999L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 999L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(ItemErrorCode.PRODUCT_NOT_FOUND); @@ -170,7 +172,7 @@ void shouldThrowWhenProductIsNotActive() { given(productRepository.findById(3L)).willReturn(Optional.of(product)); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(ItemErrorCode.PRODUCT_NOT_AVAILABLE); @@ -185,7 +187,7 @@ void shouldBlockWhenPendingRequestExists() { given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(true); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PENDING_REQUEST_ALREADY_EXISTS); @@ -206,7 +208,7 @@ void shouldThrowWhenPurchaseLimitExceeded() { .willReturn(0L); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PURCHASE_LIMIT_EXCEEDED); @@ -228,7 +230,7 @@ void shouldThrowWhenProductPurchaseCountLimitExceeded() { .willReturn(0L); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.PRODUCT_PURCHASE_LIMIT_EXCEEDED); @@ -248,7 +250,7 @@ void shouldThrowWhenFirstPurchaseOnlyProductHasExistingPurchase() { given(orderRepository.existsApprovedOrActivePendingOrder(eq(100L), any())).willReturn(true); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.FIRST_PURCHASE_ONLY); @@ -274,7 +276,7 @@ void shouldCreateFirstPurchaseOnlyOrderWhenNoExistingPurchase() { given(paymentOrderProperties.expireMinutes()).willReturn(43200L); // when - shopService.requestPurchase(100L, 3L); + shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY); // then then(orderRepository).should().save(any(Order.class)); @@ -350,7 +352,7 @@ void shouldThrowWhenRealNameMissing() { given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, null, "길동이")); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.REAL_NAME_REQUIRED); @@ -366,7 +368,7 @@ void shouldThrowWhenUsernameMissing() { given(userOrderClient.getOrdererInfo(100L)).willReturn(new OrdererInfoDto(100L, "홍길동", null)); // when & then - assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L)) + assertThatThrownBy(() -> shopService.requestPurchase(100L, 3L, DEFAULT_QUANTITY)) .isInstanceOf(BusinessException.class) .extracting(exception -> ((BusinessException)exception).getErrorCode()) .isEqualTo(PaymentErrorCode.USERNAME_REQUIRED); @@ -445,4 +447,105 @@ private Product product(String name, int price, boolean isActive, boolean isBund .isBundle(isBundle) .build(); } + + @Test + @DisplayName("여러 개 구매 시 구매 수량만큼 가격과 지급 아이템 수량을 증가시켜 주문을 생성한다") + void shouldCreateOrderWithMultipleQuantity() { + // given + int quantity = 3; + + Product product = product("매칭권 10개 (+옵션권 5개)", 5000, true); + ReflectionTestUtils.setField(product, "id", 3L); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(10) + .build() + ); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.OPTION_TICKET) + .quantity(10) + .build() + ); + + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); + + given(userOrderClient.getOrdererInfo(100L)) + .willReturn(new OrdererInfoDto(100L, "홍길동", "길동이")); + + given(paymentOrderProperties.expireMinutes()).willReturn(43200L); + + // when + shopService.requestPurchase(100L, 3L, quantity); + + // then + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(Order.class); + then(orderRepository).should().save(orderCaptor.capture()); + + Order savedOrder = orderCaptor.getValue(); + + assertThat(savedOrder.getRequestedPrice()).isEqualTo(15000); + assertThat(savedOrder.getExpectedPrice()).isEqualTo(15000); + + assertThat(savedOrder.getOrderItems()).hasSize(2); + + assertThat(savedOrder.getOrderItems()).anyMatch( + item -> + item.getItemType() == ItemType.MATCHING_TICKET + && item.getQuantity() == 30 + ); + + assertThat(savedOrder.getOrderItems()).anyMatch( + item -> + item.getItemType() == ItemType.OPTION_TICKET + && item.getQuantity() == 30 + ); + + then(orderOutboxService).should().enqueueOrderCreated(savedOrder); + } + + @Test + @DisplayName("여러 개 구매 시 전체 구매 수량 기준으로 아이템 보유 한도를 검증한다") + void shouldThrowWhenMultipleQuantityExceedsPurchaseLimit() { + // given + int quantity = 99; + + Product product = product("매칭권 패키지", 1000, true); + + product.addReward( + ProductReward.builder() + .itemType(ItemType.MATCHING_TICKET) + .quantity(2) + .build() + ); + + given(productRepository.findById(3L)).willReturn(Optional.of(product)); + given(orderRepository.existsActivePendingOrder(eq(100L), any())).willReturn(false); + + given(itemRepository.sumUsableQuantityByMemberIdAndItemType( + 100L, + ItemType.MATCHING_TICKET + )).willReturn(25L); + + given(orderRepository.sumActivePendingQuantityByMemberIdAndItemType( + eq(100L), + eq(ItemType.MATCHING_TICKET), + any() + )).willReturn(0L); + + // when & then + assertThatThrownBy(() -> + shopService.requestPurchase(100L, 3L, quantity) + ) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(PaymentErrorCode.PURCHASE_LIMIT_EXCEEDED); + + then(userOrderClient).should(never()).getOrdererInfo(any()); + then(orderRepository).should(never()).save(any()); + } } diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java index 58d6212..d6ae489 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; +import com.comatching.item.infra.controller.admin.AdminRouletteController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test;