Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public interface ShopService {

List<ProductResponse> getActiveProducts(Long memberId, Boolean isBundle);

void requestPurchase(Long memberId, Long productId);
void requestPurchase(Long memberId, Long productId, int quantity);

PurchasePendingStatusResponse getMyPurchaseRequestStatus(Long memberId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public List<ProductResponse> 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));

Expand All @@ -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);
Expand All @@ -95,16 +95,16 @@ 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();

product.getRewards().forEach(reward -> order.addOrderItem(
OrderItem.builder()
.itemType(reward.getItemType())
.quantity(reward.getQuantity())
.quantity(reward.getQuantity() * quantity)
.build()
));

Expand Down Expand Up @@ -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<ItemType, Integer> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,9 +55,10 @@ public ResponseEntity<ApiResponse<PurchaseLimitResponse>> getMyPurchaseLimits(
@PostMapping("/purchase/{productId}")
public ResponseEntity<ApiResponse<Void>> 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());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.comatching.item.infra.controller;
package com.comatching.item.infra.controller.admin;

import java.util.List;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class ShopServiceImplTest {
@Mock
private PaymentOrderProperties paymentOrderProperties;

private static final int DEFAULT_QUANTITY = 1;

@Test
@DisplayName("상품 ID 기반 요청이면 상품 가격/구성품으로 주문을 생성한다")
void shouldCreateOrderFromProductSnapshot() {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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<Order> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading