diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java index db67e7263..4cd56cda9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java @@ -37,10 +37,10 @@ public List getAddresses(String loginId) { .toList(); } - public void update(String loginId, Long addressId, String label, String recipientName, String recipientPhone, - String zipCode, String address1, String address2) { + public void updateInfo(String loginId, Long addressId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { Long memberId = getMemberId(loginId); - addressService.update(addressId, memberId, label, recipientName, recipientPhone, + addressService.updateInfo(addressId, memberId, label, recipientName, recipientPhone, zipCode, address1, address2); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 134ed7823..738aa9b03 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -37,8 +37,8 @@ public PagedInfo getBrands(String keyword, int page, int size) { ); } - public void update(Long id, String name, String description) { - brandService.update(id, name, description); + public void updateInfo(Long id, String name, String description) { + brandService.updateInfo(id, name, description); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java new file mode 100644 index 000000000..33df74972 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -0,0 +1,99 @@ +package com.loopers.application.coupon; + +import com.loopers.application.PagedInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.MemberCoupon; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class CouponFacade { + + private final MemberService memberService; + private final CouponService couponService; + + // ── Admin ── + + public CouponInfo createCoupon(String name, String type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, Integer validDays, Integer totalQuantity) { + CouponType couponType; + try { + couponType = CouponType.valueOf(type); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 쿠폰 타입입니다: " + type); + } + Coupon coupon = couponService.createCoupon(name, couponType, value, minOrderAmount, expiredAt, validDays, totalQuantity); + return CouponInfo.of(coupon); + } + + public CouponInfo getCoupon(Long couponId) { + Coupon coupon = couponService.getCoupon(couponId); + return CouponInfo.of(coupon); + } + + public PagedInfo getCoupons(int page, int size) { + PageResult result = couponService.getCoupons(page, size); + List infos = result.content().stream().map(CouponInfo::of).toList(); + return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size()); + } + + public void updateCoupon(Long couponId, String name, Long value, Long minOrderAmount, LocalDateTime expiredAt, Integer validDays) { + couponService.updateCoupon(couponId, name, value, minOrderAmount, expiredAt, validDays); + } + + public void deleteCoupon(Long couponId) { + couponService.deleteCoupon(couponId); + } + + public PagedInfo getIssuedCoupons(Long couponId, int page, int size) { + Coupon coupon = couponService.getCoupon(couponId); + CouponInfo couponInfo = CouponInfo.of(coupon); + PageResult result = couponService.getIssuedCoupons(couponId, page, size); + List infos = result.content().stream() + .map(mc -> MemberCouponInfo.of(mc, couponInfo)) + .toList(); + return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size()); + } + + // ── 대고객 ── + + public MemberCouponInfo issueCoupon(String loginId, Long couponId) { + Long memberId = getMemberId(loginId); + MemberCoupon memberCoupon = couponService.issueCoupon(couponId, memberId); + Coupon coupon = couponService.getCoupon(couponId); + return MemberCouponInfo.of(memberCoupon, CouponInfo.of(coupon)); + } + + public PagedInfo getMyCoupons(String loginId, int page, int size) { + Long memberId = getMemberId(loginId); + PageResult result = couponService.getMemberCoupons(memberId, page, size); + + List couponIds = result.content().stream().map(MemberCoupon::getCouponId).distinct().toList(); + Map couponMap = couponService.getCoupons(couponIds).stream() + .collect(Collectors.toMap(Coupon::getId, Function.identity())); + + List infos = result.content().stream() + .map(mc -> MemberCouponInfo.of(mc, CouponInfo.of(couponMap.get(mc.getCouponId())))) + .toList(); + return new PagedInfo<>(infos, result.totalElements(), result.totalPages(), result.page(), result.size()); + } + + private Long getMemberId(String loginId) { + Member member = memberService.getMemberByLoginId(loginId); + return member.getId(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java new file mode 100644 index 000000000..c46538c81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; + +import java.time.LocalDateTime; + +public record CouponInfo( + Long id, + String name, + String type, + Long value, + Long minOrderAmount, + LocalDateTime expiredAt, + Integer validDays, + Integer totalQuantity, + int issuedQuantity +) { + public static CouponInfo of(Coupon coupon) { + return new CouponInfo( + coupon.getId(), + coupon.getName(), + coupon.getType().name(), + coupon.getValue(), + coupon.getMinOrderAmount(), + coupon.getExpiredAt(), + coupon.getValidDays(), + coupon.getTotalQuantity(), + coupon.getIssuedQuantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/MemberCouponInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/MemberCouponInfo.java new file mode 100644 index 000000000..5816492a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/MemberCouponInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.MemberCoupon; + +import java.time.LocalDateTime; + +public record MemberCouponInfo( + Long id, + Long memberId, + Long couponId, + String status, + LocalDateTime usedAt, + LocalDateTime expiredAt, + CouponInfo coupon +) { + public static MemberCouponInfo of(MemberCoupon memberCoupon, CouponInfo couponInfo) { + return new MemberCouponInfo( + memberCoupon.getId(), + memberCoupon.getMemberId(), + memberCoupon.getCouponId(), + memberCoupon.getStatus().name(), + memberCoupon.getUsedAt(), + memberCoupon.getExpiredAt(), + couponInfo + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index f4978b1c8..bbdf35009 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -36,11 +36,14 @@ public LikeToggleInfo toggleProductLike(String loginId, Long productId) { boolean liked = likeService.toggleLike(memberId, LikeTargetType.PRODUCT, productId); - int likeCount = liked - ? productService.increaseLikeCount(productId) - : productService.decreaseLikeCount(productId); - - return new LikeToggleInfo(liked, likeCount); + if (liked) { + productService.increaseLikeCount(productId); + } else { + productService.decreaseLikeCount(productId); + } + + Product product = productService.getProduct(productId); + return new LikeToggleInfo(liked, product.getLikeCount()); } @Transactional @@ -50,11 +53,14 @@ public LikeToggleInfo toggleBrandLike(String loginId, Long brandId) { boolean liked = likeService.toggleLike(memberId, LikeTargetType.BRAND, brandId); - int likeCount = liked - ? brandService.increaseLikeCount(brandId) - : brandService.decreaseLikeCount(brandId); + if (liked) { + brandService.increaseLikeCount(brandId); + } else { + brandService.decreaseLikeCount(brandId); + } - return new LikeToggleInfo(liked, likeCount); + Brand brand = brandService.getBrand(brandId); + return new LikeToggleInfo(liked, brand.getLikeCount()); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java index 1b36856f3..cf992e99f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -5,6 +5,8 @@ import com.loopers.domain.member.Gender; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -22,7 +24,12 @@ public boolean authenticate(String loginId, String rawPassword) { public MemberInfo register(String loginId, String rawPassword, String name, LocalDate birthDate, String gender, String email, String phone) { - Gender genderEnum = Gender.valueOf(gender); + Gender genderEnum; + try { + genderEnum = Gender.valueOf(gender); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 성별입니다: " + gender); + } Member member = memberService.register(loginId, rawPassword, name, birthDate, genderEnum, email, phone); return MemberInfo.from(member); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 1e00bd554..d0466bcd4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -4,6 +4,7 @@ import com.loopers.domain.PageResult; import com.loopers.domain.address.Address; import com.loopers.domain.address.AddressService; +import com.loopers.domain.coupon.CouponService; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.domain.order.Order; @@ -16,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -28,9 +30,11 @@ public class OrderFacade { private final ProductService productService; private final OrderService orderService; private final AddressService addressService; + private final CouponService couponService; @Transactional - public OrderInfo createOrder(String loginId, Long addressId, List itemRequests) { + public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId, + List itemRequests) { Long memberId = getMemberId(loginId); // 1. 배송지 검증 + 스냅샷 @@ -42,29 +46,45 @@ public OrderInfo createOrder(String loginId, Long addressId, List mergedItems = orderService.mergeOrderItems(serviceRequests); - // 3. 상품 검증 + 재고 차감 + totalAmount 계산 - long totalAmount = 0L; + // 3. 상품 검증 + 재고 차감 + originalAmount 계산 + long originalAmount = 0L; List commands = new java.util.ArrayList<>(); - for (Map.Entry entry : mergedItems.entrySet()) { + List> sortedEntries = mergedItems.entrySet().stream() + .sorted(Comparator.comparingLong(Map.Entry::getKey)) + .toList(); + + for (Map.Entry entry : sortedEntries) { Long productId = entry.getKey(); int quantity = entry.getValue(); - Product product = productService.getProduct(productId); + Product product = productService.getProductForUpdate(productId); product.validateOrderQuantity(quantity); product.decreaseStock(quantity); - totalAmount += product.getPrice() * quantity; + originalAmount += product.getPrice() * quantity; commands.add(new OrderService.OrderItemCommand( productId, product.getName(), product.getPrice(), quantity )); } - // 4. 주문 생성 - Order order = orderService.createOrder( - memberId, address.getRecipientName(), address.getRecipientPhone(), - address.getZipCode(), address.getAddress1(), address.getAddress2(), totalAmount - ); + // 4. 쿠폰 적용 + Order order; + if (memberCouponId != null) { + CouponService.CouponApplyResult couponResult = couponService.useCoupon(memberCouponId, memberId, originalAmount); + long totalAmount = originalAmount - couponResult.discountAmount(); + + order = orderService.createOrder( + memberId, address.getRecipientName(), address.getRecipientPhone(), + address.getZipCode(), address.getAddress1(), address.getAddress2(), + totalAmount, couponResult.memberCouponId(), originalAmount, couponResult.discountAmount() + ); + } else { + order = orderService.createOrder( + memberId, address.getRecipientName(), address.getRecipientPhone(), + address.getZipCode(), address.getAddress1(), address.getAddress2(), originalAmount + ); + } // 5. 주문 항목 생성 List items = orderService.createOrderItems(order.getId(), commands); @@ -77,29 +97,35 @@ public void cancelOrder(String loginId, Long orderId) { Long memberId = getMemberId(loginId); // 소유권 검증 + 취소 상태 체크 + 상태 전이 (도메인 규칙) - List items = orderService.cancelOrder(orderId, memberId); + OrderService.CancelOrderResult result = orderService.cancelOrder(orderId, memberId); + + // 재고 복원 (cross-domain orchestration) — productId 순 정렬로 데드락 방지 + List sortedItems = result.items().stream() + .sorted(Comparator.comparingLong(OrderItem::getProductId)) + .toList(); - // 재고 복원 (cross-domain orchestration) - for (OrderItem item : items) { - Product product = productService.getProduct(item.getProductId()); + for (OrderItem item : sortedItems) { + Product product = productService.getProductForUpdate(item.getProductId()); product.increaseStock(item.getQuantity()); } + + // 쿠폰 복원 + if (result.order().getMemberCouponId() != null) { + couponService.restoreCoupon(result.order().getMemberCouponId()); + } } - @Transactional public OrderInfo updateShippingAddress(String loginId, Long orderId, String recipientName, String recipientPhone, String zipCode, String address1, String address2) { Long memberId = getMemberId(loginId); - Order order = orderService.getOrderForMember(orderId, memberId); - - order.updateShippingAddress(recipientName, recipientPhone, zipCode, address1, address2); + Order order = orderService.updateShippingAddress(orderId, memberId, + recipientName, recipientPhone, zipCode, address1, address2); List items = orderService.getOrderItems(orderId); return OrderInfo.of(order, items); } - @Transactional(readOnly = true) public OrderInfo getOrder(String loginId, Long orderId) { Long memberId = getMemberId(loginId); Order order = orderService.getOrderForMember(orderId, memberId); @@ -107,7 +133,6 @@ public OrderInfo getOrder(String loginId, Long orderId) { return OrderInfo.of(order, items); } - @Transactional(readOnly = true) public PagedInfo getMyOrders(String loginId, LocalDate startAt, LocalDate endAt, int page, int size) { Long memberId = getMemberId(loginId); @@ -115,14 +140,12 @@ public PagedInfo getMyOrders(String loginId, LocalDate startAt return toPagedSummary(result); } - @Transactional(readOnly = true) public OrderInfo getOrderForAdmin(Long orderId) { Order order = orderService.getOrder(orderId); List items = orderService.getOrderItems(orderId); return OrderInfo.of(order, items); } - @Transactional(readOnly = true) public PagedInfo getOrdersForAdmin(Long memberId, int page, int size) { PageResult result = orderService.getOrders(memberId, page, size); return toPagedSummary(result); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 0d524f9f7..58b77506a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -15,6 +15,9 @@ public record OrderInfo( String address1, String address2, Long totalAmount, + Long memberCouponId, + Long originalAmount, + Long discountAmount, String status, List items, ZonedDateTime createdAt @@ -29,6 +32,9 @@ public static OrderInfo of(Order order, List items) { order.getAddress1(), order.getAddress2(), order.getTotalAmount(), + order.getMemberCouponId(), + order.getOriginalAmount(), + order.getDiscountAmount(), order.getStatus().name(), items.stream().map(OrderItemInfo::from).toList(), order.getCreatedAt() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index e086edd2e..a1990c92a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -8,6 +8,8 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -36,7 +38,12 @@ public ProductInfo getProduct(Long id) { } public PagedInfo getProducts(String keyword, Long brandId, String sort, int page, int size) { - ProductSortType sortType = ProductSortType.valueOf(sort); + ProductSortType sortType; + try { + sortType = ProductSortType.valueOf(sort); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 정렬 타입입니다: " + sort); + } PageResult result = productService.getProducts(keyword, brandId, sortType, page, size); List brandIds = result.content().stream() .map(Product::getBrandId).distinct().toList(); @@ -54,8 +61,8 @@ public PagedInfo getProducts(String keyword, Long brandId, String s ); } - public void update(Long id, String name, String description, Long price, int maxOrderQuantity) { - productService.update(id, name, description, price, maxOrderQuantity); + public void updateInfo(Long id, String name, String description, Long price, int maxOrderQuantity) { + productService.updateInfo(id, name, description, price, maxOrderQuantity); } public void delete(Long id) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java index 2f6b9de4b..d18612fa5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java @@ -58,7 +58,7 @@ public static Address create(Long memberId, String label, String recipientName, return new Address(memberId, label, recipientName, recipientPhone, zipCode, address1, address2, isDefault); } - public void update(String label, String recipientName, String recipientPhone, + public void updateInfo(String label, String recipientName, String recipientPhone, String zipCode, String address1, String address2) { validateNotBlank(label, "배송지명은 필수입니다."); validateNotBlank(recipientName, "수령인 이름은 필수입니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java index 2a1971b2c..37332aecd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java @@ -42,10 +42,10 @@ public List
getAddresses(Long memberId) { } @Transactional - public void update(Long id, Long memberId, String label, String recipientName, String recipientPhone, - String zipCode, String address1, String address2) { + public void updateInfo(Long id, Long memberId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { Address address = getAddress(id, memberId); - address.update(label, recipientName, recipientPhone, zipCode, address1, address2); + address.updateInfo(label, recipientName, recipientPhone, zipCode, address1, address2); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index aead85e36..25fdfc53b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -32,7 +32,7 @@ public static Brand create(String name, String description) { return new Brand(name, description); } - public void update(String name, String description) { + public void updateInfo(String name, String description) { validateNotBlank(name, "브랜드명은 필수입니다."); this.name = name; this.description = description; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 61bccc0e1..b6521d048 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -2,4 +2,6 @@ public interface BrandRepository { Brand save(Brand brand); + int increaseLikeCount(Long id); + int decreaseLikeCount(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 7cefc6d15..35fe53465 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -32,9 +32,9 @@ public Brand getBrand(Long id) { } @Transactional - public void update(Long id, String name, String description) { + public void updateInfo(Long id, String name, String description) { Brand brand = getBrand(id); - brand.update(name, description); + brand.updateInfo(name, description); brandRepository.save(brand); } @@ -46,17 +46,19 @@ public void delete(Long id) { } @Transactional - public int increaseLikeCount(Long id) { - Brand brand = getBrand(id); - brand.increaseLikeCount(); - return brand.getLikeCount(); + public void increaseLikeCount(Long id) { + int updatedCount = brandRepository.increaseLikeCount(id); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } } @Transactional - public int decreaseLikeCount(Long id) { - Brand brand = getBrand(id); - brand.decreaseLikeCount(); - return brand.getLikeCount(); + public void decreaseLikeCount(Long id) { + int updatedCount = brandRepository.decreaseLikeCount(id); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..13e036250 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,166 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon") +public class Coupon extends BaseEntity { + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponType type; + + @Column(nullable = false) + private Long value; + + @Column(name = "min_order_amount") + private Long minOrderAmount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "valid_days") + private Integer validDays; + + @Column(name = "total_quantity") + private Integer totalQuantity; + + @Column(name = "issued_quantity", nullable = false) + private int issuedQuantity; + + protected Coupon() {} + + private Coupon(String name, CouponType type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, Integer validDays, Integer totalQuantity) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + this.validDays = validDays; + this.totalQuantity = totalQuantity; + this.issuedQuantity = 0; + } + + public static Coupon create(String name, CouponType type, Long value, Long minOrderAmount, LocalDateTime expiredAt) { + return create(name, type, value, minOrderAmount, expiredAt, null, null); + } + + public static Coupon create(String name, CouponType type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, Integer totalQuantity) { + return create(name, type, value, minOrderAmount, expiredAt, null, totalQuantity); + } + + public static Coupon create(String name, CouponType type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, Integer validDays, Integer totalQuantity) { + validateNotBlank(name, "쿠폰명은 필수입니다."); + validateNotNull(type, "쿠폰 타입은 필수입니다."); + validatePositive(value, "할인 값은 0보다 커야 합니다."); + validateNotNull(expiredAt, "만료일은 필수입니다."); + if (type == CouponType.RATE && value > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100%를 초과할 수 없습니다."); + } + return new Coupon(name, type, value, minOrderAmount, expiredAt, validDays, totalQuantity); + } + + public void issueOne() { + if (totalQuantity != null && issuedQuantity >= totalQuantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 수량이 모두 소진되었습니다."); + } + this.issuedQuantity++; + } + + public void updateInfo(String name, Long value, Long minOrderAmount, LocalDateTime expiredAt, Integer validDays) { + validateNotBlank(name, "쿠폰명은 필수입니다."); + validatePositive(value, "할인 값은 0보다 커야 합니다."); + validateNotNull(expiredAt, "만료일은 필수입니다."); + if (this.type == CouponType.RATE && value > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100%를 초과할 수 없습니다."); + } + this.name = name; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + this.validDays = validDays; + } + + public long calculateDiscount(long orderAmount) { + if (this.type == CouponType.FIXED) { + return Math.min(this.value, orderAmount); + } + return orderAmount * this.value / 100; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } + + public void validateMinOrderAmount(long orderAmount) { + if (this.minOrderAmount != null && orderAmount < this.minOrderAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, + "최소 주문 금액(" + this.minOrderAmount + "원) 이상이어야 합니다."); + } + } + + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePositive(Long value, String message) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public String getName() { + return name; + } + + public CouponType getType() { + return type; + } + + public Long getValue() { + return value; + } + + public Long getMinOrderAmount() { + return minOrderAmount; + } + + public LocalDateTime getExpiredAt() { + return expiredAt; + } + + public Integer getValidDays() { + return validDays; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public int getIssuedQuantity() { + return issuedQuantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponReader.java new file mode 100644 index 000000000..4c31513ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponReader.java @@ -0,0 +1,13 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.PageResult; + +import java.util.List; +import java.util.Optional; + +public interface CouponReader { + Optional findById(Long id); + Optional findByIdForUpdate(Long id); + List findAllByIdIn(List ids); + PageResult findAll(int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..db42f85a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.coupon; + +public interface CouponRepository { + Coupon save(Coupon coupon); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java new file mode 100644 index 000000000..9440ec281 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -0,0 +1,133 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CouponService { + + private final CouponRepository couponRepository; + private final CouponReader couponReader; + private final MemberCouponRepository memberCouponRepository; + private final MemberCouponReader memberCouponReader; + + @Transactional + public Coupon createCoupon(String name, CouponType type, Long value, Long minOrderAmount, LocalDateTime expiredAt) { + return createCoupon(name, type, value, minOrderAmount, expiredAt, null); + } + + @Transactional + public Coupon createCoupon(String name, CouponType type, Long value, Long minOrderAmount, LocalDateTime expiredAt, Integer totalQuantity) { + return createCoupon(name, type, value, minOrderAmount, expiredAt, null, totalQuantity); + } + + @Transactional + public Coupon createCoupon(String name, CouponType type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, Integer validDays, Integer totalQuantity) { + Coupon coupon = Coupon.create(name, type, value, minOrderAmount, expiredAt, validDays, totalQuantity); + return couponRepository.save(coupon); + } + + @Transactional(readOnly = true) + public Coupon getCoupon(Long couponId) { + return couponReader.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getCoupons(List couponIds) { + return couponReader.findAllByIdIn(couponIds); + } + + @Transactional(readOnly = true) + public PageResult getCoupons(int page, int size) { + return couponReader.findAll(page, size); + } + + @Transactional + public void updateCoupon(Long couponId, String name, Long value, Long minOrderAmount, LocalDateTime expiredAt, Integer validDays) { + Coupon coupon = getCoupon(couponId); + coupon.updateInfo(name, value, minOrderAmount, expiredAt, validDays); + } + + @Transactional + public void deleteCoupon(Long couponId) { + Coupon coupon = getCoupon(couponId); + coupon.delete(); + } + + @Transactional + public MemberCoupon issueCoupon(Long couponId, Long memberId) { + Coupon coupon = couponReader.findByIdForUpdate(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + + if (coupon.isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다."); + } + + memberCouponReader.findByMemberIdAndCouponId(memberId, couponId) + .ifPresent(mc -> { + throw new CoreException(ErrorType.CONFLICT, "이미 발급받은 쿠폰입니다."); + }); + + coupon.issueOne(); + LocalDateTime memberExpiredAt = coupon.getValidDays() != null + ? LocalDateTime.now().plusDays(coupon.getValidDays()) + : coupon.getExpiredAt(); + MemberCoupon memberCoupon = MemberCoupon.create(memberId, couponId, memberExpiredAt); + return memberCouponRepository.save(memberCoupon); + } + + @Transactional(readOnly = true) + public MemberCoupon getMemberCoupon(Long memberCouponId) { + return memberCouponReader.findById(memberCouponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자 쿠폰을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public PageResult getMemberCoupons(Long memberId, int page, int size) { + return memberCouponReader.findAllByMemberId(memberId, page, size); + } + + @Transactional(readOnly = true) + public PageResult getIssuedCoupons(Long couponId, int page, int size) { + return memberCouponReader.findAllByCouponId(couponId, page, size); + } + + @Transactional + public CouponApplyResult useCoupon(Long memberCouponId, Long memberId, long orderAmount) { + MemberCoupon mc = memberCouponReader.findById(memberCouponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자 쿠폰을 찾을 수 없습니다.")); + mc.validateOwner(memberId); + + Coupon coupon = couponReader.findById(mc.getCouponId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + + if (mc.isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 사용할 수 없습니다."); + } + + coupon.validateMinOrderAmount(orderAmount); + long discount = coupon.calculateDiscount(orderAmount); + mc.use(); + + return new CouponApplyResult(coupon.getId(), mc.getId(), discount); + } + + @Transactional + public void restoreCoupon(Long memberCouponId) { + MemberCoupon mc = memberCouponReader.findById(memberCouponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자 쿠폰을 찾을 수 없습니다.")); + mc.restore(); + } + + public record CouponApplyResult(Long couponId, Long memberCouponId, long discountAmount) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java new file mode 100644 index 000000000..6924faf8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponStatus { + AVAILABLE, + USED, + EXPIRED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..ed11e42f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public enum CouponType { + FIXED, + RATE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCoupon.java new file mode 100644 index 000000000..b04fd11b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCoupon.java @@ -0,0 +1,126 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "member_coupon", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "coupon_id"}) +}) +public class MemberCoupon extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponStatus status; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Version + private Long version; + + protected MemberCoupon() {} + + private MemberCoupon(Long memberId, Long couponId, LocalDateTime expiredAt) { + this.memberId = memberId; + this.couponId = couponId; + this.expiredAt = expiredAt; + this.status = CouponStatus.AVAILABLE; + } + + public static MemberCoupon create(Long memberId, Long couponId, LocalDateTime expiredAt) { + validateNotNull(memberId, "회원 ID는 필수입니다."); + validateNotNull(couponId, "쿠폰 ID는 필수입니다."); + validateNotNull(expiredAt, "만료일은 필수입니다."); + return new MemberCoupon(memberId, couponId, expiredAt); + } + + public void use() { + if (isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 사용할 수 없습니다."); + } + if (this.status != CouponStatus.AVAILABLE) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 가능한 상태의 쿠폰만 사용할 수 있습니다."); + } + this.status = CouponStatus.USED; + this.usedAt = LocalDateTime.now(); + } + + public void expire() { + if (this.status == CouponStatus.USED) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰은 만료 처리할 수 없습니다."); + } + this.status = CouponStatus.EXPIRED; + } + + public void restore() { + if (this.status != CouponStatus.USED) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용된 쿠폰만 복원할 수 있습니다."); + } + this.status = CouponStatus.AVAILABLE; + this.usedAt = null; + } + + public boolean isUsable() { + return this.status == CouponStatus.AVAILABLE && !isExpired(); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } + + public void validateOwner(Long memberId) { + if (!this.memberId.equals(memberId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰의 소유자가 아닙니다."); + } + } + + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public Long getMemberId() { + return memberId; + } + + public Long getCouponId() { + return couponId; + } + + public LocalDateTime getExpiredAt() { + return expiredAt; + } + + public CouponStatus getStatus() { + return status; + } + + public LocalDateTime getUsedAt() { + return usedAt; + } + + public Long getVersion() { + return version; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponReader.java new file mode 100644 index 000000000..3b153b822 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponReader.java @@ -0,0 +1,12 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.PageResult; + +import java.util.Optional; + +public interface MemberCouponReader { + Optional findById(Long id); + Optional findByMemberIdAndCouponId(Long memberId, Long couponId); + PageResult findAllByMemberId(Long memberId, int page, int size); + PageResult findAllByCouponId(Long couponId, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponRepository.java new file mode 100644 index 000000000..78c242f08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/MemberCouponRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.coupon; + +public interface MemberCouponRepository { + MemberCoupon save(MemberCoupon memberCoupon); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 6164d42cb..86c7fc986 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -34,6 +34,15 @@ public class Order extends BaseEntity { @Column(name = "total_amount", nullable = false) private Long totalAmount; + @Column(name = "member_coupon_id") + private Long memberCouponId; + + @Column(name = "original_amount") + private Long originalAmount; + + @Column(name = "discount_amount") + private Long discountAmount; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private OrderStatus status; @@ -41,7 +50,8 @@ public class Order extends BaseEntity { protected Order() {} private Order(Long memberId, String recipientName, String recipientPhone, - String zipCode, String address1, String address2, Long totalAmount) { + String zipCode, String address1, String address2, Long totalAmount, + Long memberCouponId, Long originalAmount, Long discountAmount) { this.memberId = memberId; this.recipientName = recipientName; this.recipientPhone = recipientPhone; @@ -49,18 +59,36 @@ private Order(Long memberId, String recipientName, String recipientPhone, this.address1 = address1; this.address2 = address2; this.totalAmount = totalAmount; + this.memberCouponId = memberCouponId; + this.originalAmount = originalAmount; + this.discountAmount = discountAmount; this.status = OrderStatus.COMPLETED; } public static Order create(Long memberId, String recipientName, String recipientPhone, String zipCode, String address1, String address2, Long totalAmount) { + return create(memberId, recipientName, recipientPhone, zipCode, address1, address2, + totalAmount, null, null, null); + } + + public static Order create(Long memberId, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, Long totalAmount, + Long memberCouponId, Long originalAmount, Long discountAmount) { validateNotNull(memberId, "회원 ID는 필수입니다."); validateNotBlank(recipientName, "수령인 이름은 필수입니다."); validateNotBlank(recipientPhone, "수령인 전화번호는 필수입니다."); validateNotBlank(zipCode, "우편번호는 필수입니다."); validateNotBlank(address1, "기본주소는 필수입니다."); validatePositive(totalAmount, "주문 총액은 0보다 커야 합니다."); - return new Order(memberId, recipientName, recipientPhone, zipCode, address1, address2, totalAmount); + if (memberCouponId != null) { + validateNotNull(originalAmount, "쿠폰 적용 시 원래 금액은 필수입니다."); + validateNotNull(discountAmount, "쿠폰 적용 시 할인 금액은 필수입니다."); + if (totalAmount != originalAmount - discountAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 총액이 원래 금액에서 할인 금액을 뺀 값과 일치하지 않습니다."); + } + } + return new Order(memberId, recipientName, recipientPhone, zipCode, address1, address2, + totalAmount, memberCouponId, originalAmount, discountAmount); } public void cancel() { @@ -132,6 +160,18 @@ public Long getTotalAmount() { return totalAmount; } + public Long getMemberCouponId() { + return memberCouponId; + } + + public Long getOriginalAmount() { + return originalAmount; + } + + public Long getDiscountAmount() { + return discountAmount; + } + public OrderStatus getStatus() { return status; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java index bb3001f76..824a3cc20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java @@ -8,6 +8,7 @@ public interface OrderReader { Optional findById(Long id); Optional findByIdAndMemberId(Long id, Long memberId); + Optional findByIdAndMemberIdForUpdate(Long id, Long memberId); PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size); PageResult findAll(Long memberId, int page, int size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 798fb4762..87bf384ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -28,6 +28,15 @@ public Order createOrder(Long memberId, String recipientName, String recipientPh return orderRepository.save(order); } + @Transactional + public Order createOrder(Long memberId, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, Long totalAmount, + Long memberCouponId, Long originalAmount, Long discountAmount) { + Order order = Order.create(memberId, recipientName, recipientPhone, zipCode, address1, address2, + totalAmount, memberCouponId, originalAmount, discountAmount); + return orderRepository.save(order); + } + @Transactional public List createOrderItems(Long orderId, List commands) { List items = commands.stream() @@ -82,8 +91,19 @@ public Map mergeOrderItems(List requests) { return merged; } - public List cancelOrder(Long orderId, Long memberId) { - Order order = orderReader.findByIdAndMemberId(orderId, memberId) + @Transactional + public Order updateShippingAddress(Long orderId, Long memberId, + String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + Order order = orderReader.findByIdAndMemberIdForUpdate(orderId, memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + order.updateShippingAddress(recipientName, recipientPhone, zipCode, address1, address2); + return order; + } + + @Transactional + public CancelOrderResult cancelOrder(Long orderId, Long memberId) { + Order order = orderReader.findByIdAndMemberIdForUpdate(orderId, memberId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); if (order.getStatus() == OrderStatus.CANCELLED) { @@ -92,9 +112,12 @@ public List cancelOrder(Long orderId, Long memberId) { order.cancel(); - return orderItemReader.findAllByOrderId(orderId); + List items = orderItemReader.findAllByOrderId(orderId); + return new CancelOrderResult(order, items); } + public record CancelOrderResult(Order order, List items) {} + public record OrderItemRequest(Long productId, int quantity) {} public record OrderItemCommand(Long productId, String productName, Long productPrice, int quantity) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index df8317d69..995d0fe6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -51,7 +51,7 @@ public static Product create(Long brandId, String name, String description, Long return new Product(brandId, name, description, price, stockQuantity, maxOrderQuantity); } - public void update(String name, String description, Long price, int maxOrderQuantity) { + public void updateInfo(String name, String description, Long price, int maxOrderQuantity) { validateNotBlank(name, "상품명은 필수입니다."); validatePositive(price, "가격은 0보다 커야 합니다."); validatePositive((long) maxOrderQuantity, "최대 주문 수량은 0보다 커야 합니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java index 4b6f97504..f3e1e8922 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java @@ -7,6 +7,7 @@ public interface ProductReader { Optional findById(Long id); + Optional findByIdForUpdate(Long id); PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size); List findAllByIds(List ids); List findAllByBrandId(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 3bd1bd330..c3dc12ad2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -2,4 +2,6 @@ public interface ProductRepository { Product save(Product product); + int increaseLikeCount(Long id); + int decreaseLikeCount(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 6b4a28990..7e359692b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -29,9 +29,15 @@ public Product getProduct(Long id) { } @Transactional - public void update(Long id, String name, String description, Long price, int maxOrderQuantity) { - Product product = getProduct(id); - product.update(name, description, price, maxOrderQuantity); + public Product getProductForUpdate(Long id) { + return productReader.findByIdForUpdate(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional + public void updateInfo(Long id, String name, String description, Long price, int maxOrderQuantity) { + Product product = getProductForUpdate(id); + product.updateInfo(name, description, price, maxOrderQuantity); } @Transactional @@ -42,7 +48,7 @@ public void delete(Long id) { @Transactional public void updateStock(Long id, int quantity) { - Product product = getProduct(id); + Product product = getProductForUpdate(id); product.updateStock(quantity); } @@ -55,17 +61,19 @@ public void deleteAllByBrandId(Long brandId) { } @Transactional - public int increaseLikeCount(Long id) { - Product product = getProduct(id); - product.increaseLikeCount(); - return product.getLikeCount(); + public void increaseLikeCount(Long id) { + int updatedCount = productRepository.increaseLikeCount(id); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. id: " + id); + } } @Transactional - public int decreaseLikeCount(Long id) { - Product product = getProduct(id); - product.decreaseLikeCount(); - return product.getLikeCount(); + public void decreaseLikeCount(Long id) { + int updatedCount = productRepository.decreaseLikeCount(id); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. id: " + id); + } } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index abc4d618e..bff20908c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -4,6 +4,9 @@ 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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -15,4 +18,12 @@ public interface BrandJpaRepository extends JpaRepository { List findAllByIdInAndDeletedAtIsNull(List ids); Page findByDeletedAtIsNull(Pageable pageable); Page findByNameContainingAndDeletedAtIsNull(String keyword, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Brand b SET b.likeCount = b.likeCount + 1 WHERE b.id = :id AND b.deletedAt IS NULL") + int increaseLikeCount(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Brand b SET b.likeCount = b.likeCount - 1 WHERE b.id = :id AND b.deletedAt IS NULL AND b.likeCount > 0") + int decreaseLikeCount(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index a3fb2a66b..90837f1ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -14,4 +14,14 @@ public class BrandRepositoryImpl implements BrandRepository { public Brand save(Brand brand) { return brandJpaRepository.save(brand); } + + @Override + public int increaseLikeCount(Long id) { + return brandJpaRepository.increaseLikeCount(id); + } + + @Override + public int decreaseLikeCount(Long id) { + return brandJpaRepository.decreaseLikeCount(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..38b7c0a1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +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.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CouponJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByIdInAndDeletedAtIsNull(List ids); + Page findAllByDeletedAtIsNull(Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("SELECT c FROM Coupon c WHERE c.id = :id AND c.deletedAt IS NULL") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponReaderImpl.java new file mode 100644 index 000000000..ae1acae96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponReaderImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.PageResult; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CouponReaderImpl implements CouponReader { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Optional findById(Long id) { + return couponJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return couponJpaRepository.findByIdForUpdate(id); + } + + @Override + public List findAllByIdIn(List ids) { + return couponJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public PageResult findAll(int page, int size) { + Page result = couponJpaRepository.findAllByDeletedAtIsNull( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + return new PageResult<>( + result.getContent(), + result.getTotalElements(), + result.getTotalPages(), + result.getNumber(), + result.getSize() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..364508017 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponJpaRepository.java new file mode 100644 index 000000000..2c2b07c7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.MemberCoupon; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberCouponJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByMemberIdAndCouponIdAndDeletedAtIsNull(Long memberId, Long couponId); + Page findAllByMemberIdAndDeletedAtIsNull(Long memberId, Pageable pageable); + Page findAllByCouponIdAndDeletedAtIsNull(Long couponId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponReaderImpl.java new file mode 100644 index 000000000..40dfbe9a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponReaderImpl.java @@ -0,0 +1,57 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.PageResult; +import com.loopers.domain.coupon.MemberCoupon; +import com.loopers.domain.coupon.MemberCouponReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberCouponReaderImpl implements MemberCouponReader { + + private final MemberCouponJpaRepository memberCouponJpaRepository; + + @Override + public Optional findById(Long id) { + return memberCouponJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByMemberIdAndCouponId(Long memberId, Long couponId) { + return memberCouponJpaRepository.findByMemberIdAndCouponIdAndDeletedAtIsNull(memberId, couponId); + } + + @Override + public PageResult findAllByMemberId(Long memberId, int page, int size) { + Page result = memberCouponJpaRepository.findAllByMemberIdAndDeletedAtIsNull( + memberId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + return new PageResult<>( + result.getContent(), + result.getTotalElements(), + result.getTotalPages(), + result.getNumber(), + result.getSize() + ); + } + + @Override + public PageResult findAllByCouponId(Long couponId, int page, int size) { + Page result = memberCouponJpaRepository.findAllByCouponIdAndDeletedAtIsNull( + couponId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + return new PageResult<>( + result.getContent(), + result.getTotalElements(), + result.getTotalPages(), + result.getNumber(), + result.getSize() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponRepositoryImpl.java new file mode 100644 index 000000000..c8adbfe89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/MemberCouponRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.MemberCoupon; +import com.loopers.domain.coupon.MemberCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberCouponRepositoryImpl implements MemberCouponRepository { + + private final MemberCouponJpaRepository memberCouponJpaRepository; + + @Override + public MemberCoupon save(MemberCoupon memberCoupon) { + return memberCouponJpaRepository.save(memberCoupon); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 8da35dc08..08ccb5a3c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,11 +1,22 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface OrderJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); Optional findByIdAndMemberIdAndDeletedAtIsNull(Long id, Long memberId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("SELECT o FROM Order o WHERE o.id = :id AND o.memberId = :memberId AND o.deletedAt IS NULL") + Optional findByIdAndMemberIdForUpdate(@Param("id") Long id, @Param("memberId") Long memberId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java index 202089781..f21df9e6a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java @@ -34,6 +34,11 @@ public Optional findByIdAndMemberId(Long id, Long memberId) { return orderJpaRepository.findByIdAndMemberIdAndDeletedAtIsNull(id, memberId); } + @Override + public Optional findByIdAndMemberIdForUpdate(Long id, Long memberId) { + return orderJpaRepository.findByIdAndMemberIdForUpdate(id, memberId); + } + @Override public PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size) { Pageable pageable = PageRequest.of(page, size); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index f19b71a7f..728ffe3c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,7 +1,14 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -10,4 +17,17 @@ public interface ProductJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); List findAllByIdInAndDeletedAtIsNull(List ids); List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) + @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdForUpdate(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id AND p.deletedAt IS NULL") + int increaseLikeCount(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.deletedAt IS NULL AND p.likeCount > 0") + int decreaseLikeCount(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java index 5dff2fec4..809c3ef46 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java @@ -29,6 +29,11 @@ public Optional findById(Long id) { return productJpaRepository.findByIdAndDeletedAtIsNull(id); } + @Override + public Optional findByIdForUpdate(Long id) { + return productJpaRepository.findByIdForUpdate(id); + } + @Override public PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size) { Pageable pageable = PageRequest.of(page, size); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 99165b0f8..d8c2c55c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -15,4 +15,14 @@ public class ProductRepositoryImpl implements ProductRepository { public Product save(Product product) { return productJpaRepository.save(product); } + + @Override + public int increaseLikeCount(Long id) { + return productJpaRepository.increaseLikeCount(id); + } + + @Override + public int decreaseLikeCount(Long id) { + return productJpaRepository.decreaseLikeCount(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..c461fb996 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -6,6 +6,9 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -107,6 +110,24 @@ public ResponseEntity> handleNotFound(NoResourceFoundException e) return failureResponse(ErrorType.NOT_FOUND, null); } + @ExceptionHandler + public ResponseEntity> handleOptimisticLock(OptimisticLockingFailureException e) { + log.warn("OptimisticLockingFailureException : {}", e.getMessage(), e); + return failureResponse(ErrorType.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요."); + } + + @ExceptionHandler + public ResponseEntity> handlePessimisticLock(PessimisticLockingFailureException e) { + log.warn("PessimisticLockingFailureException : {}", e.getMessage(), e); + return failureResponse(ErrorType.CONFLICT, "다른 요청과 충돌이 발생했습니다. 다시 시도해주세요."); + } + + @ExceptionHandler + public ResponseEntity> handleDataIntegrity(DataIntegrityViolationException e) { + log.warn("DataIntegrityViolationException : {}", e.getMessage(), e); + return failureResponse(ErrorType.CONFLICT, "이미 처리된 요청입니다. 다시 시도해주세요."); + } + @ExceptionHandler public ResponseEntity> handle(Throwable e) { log.error("Exception : {}", e.getMessage(), e); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java index cf2cf6010..49cb6a79a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java @@ -55,7 +55,7 @@ public ApiResponse update( @RequestBody AddressV1Dto.UpdateAddressRequest body ) { String loginId = AuthUtils.getAuthenticatedLoginId(request); - addressFacade.update( + addressFacade.updateInfo( loginId, addressId, body.label(), body.recipientName(), body.recipientPhone(), body.zipCode(), body.address1(), body.address2() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index ebea83f6a..db293e2f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -35,7 +35,7 @@ public ApiResponse update( @PathVariable Long brandId, @RequestBody BrandV1Dto.UpdateRequest request ) { - brandFacade.update(brandId, request.name(), request.description()); + brandFacade.updateInfo(brandId, request.name(), request.description()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java new file mode 100644 index 000000000..41c585ccc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.PagedInfo; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.CouponInfo; +import com.loopers.application.coupon.MemberCouponInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/coupons") +public class CouponAdminV1Controller { + + private final CouponFacade couponFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse create( + @RequestBody CouponV1Dto.CreateCouponRequest body + ) { + CouponInfo info = couponFacade.createCoupon( + body.name(), body.type(), body.value(), body.minOrderAmount(), body.expiredAt(), body.validDays(), body.totalQuantity() + ); + return ApiResponse.success(CouponV1Dto.CouponResponse.from(info)); + } + + @GetMapping + public ApiResponse getCoupons( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PagedInfo result = couponFacade.getCoupons(page, size); + return ApiResponse.success(CouponV1Dto.CouponListResponse.from(result)); + } + + @GetMapping("/{couponId}") + public ApiResponse getCoupon(@PathVariable Long couponId) { + CouponInfo info = couponFacade.getCoupon(couponId); + return ApiResponse.success(CouponV1Dto.CouponResponse.from(info)); + } + + @PutMapping("/{couponId}") + public ApiResponse update( + @PathVariable Long couponId, + @RequestBody CouponV1Dto.UpdateCouponRequest body + ) { + couponFacade.updateCoupon(couponId, body.name(), body.value(), body.minOrderAmount(), body.expiredAt(), body.validDays()); + return ApiResponse.success(null); + } + + @DeleteMapping("/{couponId}") + public ApiResponse delete(@PathVariable Long couponId) { + couponFacade.deleteCoupon(couponId); + return ApiResponse.success(null); + } + + @GetMapping("/{couponId}/issues") + public ApiResponse getIssuedCoupons( + @PathVariable Long couponId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PagedInfo result = couponFacade.getIssuedCoupons(couponId, page, size); + return ApiResponse.success(CouponV1Dto.MemberCouponListResponse.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java new file mode 100644 index 000000000..b127083fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.PagedInfo; +import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.MemberCouponInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class CouponV1Controller { + + private final CouponFacade couponFacade; + + @PostMapping("/api/v1/coupons/{couponId}/issue") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse issueCoupon( + HttpServletRequest request, + @PathVariable Long couponId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + MemberCouponInfo info = couponFacade.issueCoupon(loginId, couponId); + return ApiResponse.success(CouponV1Dto.MemberCouponResponse.from(info)); + } + + @GetMapping("/api/v1/users/me/coupons") + public ApiResponse getMyCoupons( + HttpServletRequest request, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + PagedInfo result = couponFacade.getMyCoupons(loginId, page, size); + return ApiResponse.success(CouponV1Dto.MemberCouponListResponse.from(result)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java new file mode 100644 index 000000000..fa6e97fd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.PagedInfo; +import com.loopers.application.coupon.CouponInfo; +import com.loopers.application.coupon.MemberCouponInfo; + +import java.time.LocalDateTime; +import java.util.List; + +public class CouponV1Dto { + + // ── Request ── + + public record CreateCouponRequest( + String name, + String type, + Long value, + Long minOrderAmount, + LocalDateTime expiredAt, + Integer validDays, + Integer totalQuantity + ) {} + + public record UpdateCouponRequest( + String name, + Long value, + Long minOrderAmount, + LocalDateTime expiredAt, + Integer validDays + ) {} + + // ── Response ── + + public record CouponResponse(CouponDto coupon) { + public record CouponDto( + Long id, + String name, + String type, + Long value, + Long minOrderAmount, + LocalDateTime expiredAt, + Integer validDays, + Integer totalQuantity, + int issuedQuantity + ) {} + + public static CouponResponse from(CouponInfo info) { + return new CouponResponse(new CouponDto( + info.id(), info.name(), info.type(), + info.value(), info.minOrderAmount(), info.expiredAt(), + info.validDays(), info.totalQuantity(), info.issuedQuantity() + )); + } + } + + public record CouponListResponse( + List coupons, + PageInfo page + ) { + public static CouponListResponse from(PagedInfo result) { + var dtos = result.content().stream() + .map(info -> new CouponResponse.CouponDto( + info.id(), info.name(), info.type(), + info.value(), info.minOrderAmount(), info.expiredAt(), + info.validDays(), info.totalQuantity(), info.issuedQuantity() + )) + .toList(); + return new CouponListResponse(dtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages())); + } + } + + public record MemberCouponResponse(MemberCouponDto memberCoupon) { + public record MemberCouponDto( + Long id, + Long memberId, + Long couponId, + String status, + LocalDateTime usedAt, + LocalDateTime expiredAt, + CouponResponse.CouponDto coupon + ) {} + + public static MemberCouponResponse from(MemberCouponInfo info) { + return new MemberCouponResponse(new MemberCouponDto( + info.id(), info.memberId(), info.couponId(), + info.status(), info.usedAt(), info.expiredAt(), + new CouponResponse.CouponDto( + info.coupon().id(), info.coupon().name(), info.coupon().type(), + info.coupon().value(), info.coupon().minOrderAmount(), info.coupon().expiredAt(), + info.coupon().validDays(), info.coupon().totalQuantity(), info.coupon().issuedQuantity() + ) + )); + } + } + + public record MemberCouponListResponse( + List memberCoupons, + PageInfo page + ) { + public static MemberCouponListResponse from(PagedInfo result) { + var dtos = result.content().stream() + .map(info -> new MemberCouponResponse.MemberCouponDto( + info.id(), info.memberId(), info.couponId(), + info.status(), info.usedAt(), info.expiredAt(), + new CouponResponse.CouponDto( + info.coupon().id(), info.coupon().name(), info.coupon().type(), + info.coupon().value(), info.coupon().minOrderAmount(), info.coupon().expiredAt(), + info.coupon().validDays(), info.coupon().totalQuantity(), info.coupon().issuedQuantity() + ) + )) + .toList(); + return new MemberCouponListResponse(dtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages())); + } + } + + public record PageInfo(int number, int size, long totalElements, int totalPages) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 8b18ee15c..25436d377 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -29,7 +29,7 @@ public ApiResponse createOrder( List itemRequests = body.items().stream() .map(item -> new OrderFacade.OrderItemRequest(item.productId(), item.quantity())) .toList(); - OrderInfo info = orderFacade.createOrder(loginId, body.addressId(), itemRequests); + OrderInfo info = orderFacade.createOrder(loginId, body.addressId(), body.memberCouponId(), itemRequests); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 28abfab75..f0d3f588f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -12,6 +12,7 @@ public class OrderV1Dto { public record CreateOrderRequest( Long addressId, + Long memberCouponId, List items ) { public record OrderItemRequest(Long productId, int quantity) {} @@ -34,7 +35,9 @@ public static OrderResponse from(OrderInfo info) { info.id(), info.memberId(), info.recipientName(), info.recipientPhone(), info.zipCode(), info.address1(), info.address2(), - info.totalAmount(), info.status(), items, info.createdAt() + info.totalAmount(), info.memberCouponId(), + info.originalAmount(), info.discountAmount(), + info.status(), items, info.createdAt() )); } } @@ -48,6 +51,9 @@ public record OrderDto( String address1, String address2, Long totalAmount, + Long memberCouponId, + Long originalAmount, + Long discountAmount, String status, List items, ZonedDateTime createdAt diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 48c1e827a..4b763d29d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -39,7 +39,7 @@ public ApiResponse update( @PathVariable Long productId, @RequestBody ProductV1Dto.UpdateRequest request ) { - productFacade.update(productId, request.name(), request.description(), + productFacade.updateInfo(productId, request.name(), request.description(), request.price(), request.maxOrderQuantity()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java index 9531ffca2..8714247da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java @@ -90,6 +90,16 @@ private boolean requiresAuthentication(HttpServletRequest request) { return true; } + // POST /api/v1/coupons/{couponId}/issue 인증 필요 + if ("POST".equals(method) && path.startsWith("/api/v1/coupons/") && path.endsWith("/issue")) { + return true; + } + + // /api/v1/users/me/** 경로는 인증 필요 + if (path.startsWith("/api/v1/users/me")) { + return true; + } + return false; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java index db0da13f5..529775108 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java @@ -159,7 +159,7 @@ void updatesAddress_whenValid() { "12345", "서울시 강남구", null); // Act - addressService.update(1L, 1L, "회사", "김철수", "010-9876-5432", + addressService.updateInfo(1L, 1L, "회사", "김철수", "010-9876-5432", "54321", "서울시 서초구", "301동"); // Assert @@ -176,7 +176,7 @@ void updatesAddress_whenValid() { void throwsNotFound_whenNotExists() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - addressService.update(999L, 1L, "회사", "김철수", "010-9876-5432", + addressService.updateInfo(999L, 1L, "회사", "김철수", "010-9876-5432", "54321", "서울시 서초구", null) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java index 8814daf62..b63269649 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java @@ -115,7 +115,7 @@ void updatesAddress_whenFieldsAreValid() { "12345", "서울시 강남구", "101동 202호", true); // Act - address.update("회사", "김철수", "010-9876-5432", + address.updateInfo("회사", "김철수", "010-9876-5432", "54321", "서울시 서초구", "301동 402호"); // Assert @@ -139,7 +139,7 @@ void throwsBadRequest_whenLabelIsNull() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - address.update(null, "홍길동", "010-1234-5678", + address.updateInfo(null, "홍길동", "010-1234-5678", "12345", "서울시 강남구", null) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -154,7 +154,7 @@ void throwsBadRequest_whenAddress1IsBlank() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - address.update("집", "홍길동", "010-1234-5678", + address.updateInfo("집", "홍길동", "010-1234-5678", "12345", " ", null) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index 7e6cb0ae9..fdf728556 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -104,7 +104,7 @@ void updatesBrand_whenFieldsAreValid() { fakeBrandReader.addBrand(1L, brand); // Act - brandService.update(1L, "Adidas", "Impossible Is Nothing"); + brandService.updateInfo(1L, "Adidas", "Impossible Is Nothing"); // Assert assertAll( @@ -118,7 +118,7 @@ void updatesBrand_whenFieldsAreValid() { void throwsNotFound_whenNotExists() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - brandService.update(999L, "Adidas", "설명"); + brandService.updateInfo(999L, "Adidas", "설명"); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } @@ -173,6 +173,56 @@ void returnsAllBrands_whenNoKeyword() { } } + @DisplayName("브랜드 좋아요 수를 증가할 때, ") + @Nested + class IncreaseLikeCount { + + @DisplayName("존재하는 브랜드의 좋아요를 증가하면, 정상 처리된다.") + @Test + void succeeds_whenBrandExists() { + // Arrange + fakeBrandRepository.save(Brand.create("Nike", "Just Do It")); + + // Act & Assert (예외 없이 정상 수행) + brandService.increaseLikeCount(1L); + } + + @DisplayName("존재하지 않는 브랜드의 좋아요를 증가하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.increaseLikeCount(999L); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 좋아요 수를 감소할 때, ") + @Nested + class DecreaseLikeCount { + + @DisplayName("존재하는 브랜드의 좋아요를 감소하면, 정상 처리된다.") + @Test + void succeeds_whenBrandExists() { + // Arrange + fakeBrandRepository.save(Brand.create("Nike", "Just Do It")); + + // Act & Assert (예외 없이 정상 수행) + brandService.decreaseLikeCount(1L); + } + + @DisplayName("존재하지 않는 브랜드의 좋아요를 감소하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.decreaseLikeCount(999L); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + // Fake 구현체 static class FakeBrandReader implements BrandReader { private final Map brands = new HashMap<>(); @@ -226,8 +276,19 @@ static class FakeBrandRepository implements BrandRepository { @Override public Brand save(Brand brand) { - brands.put(idSequence++, brand); + Long id = idSequence++; + brands.put(id, brand); return brand; } + + @Override + public int increaseLikeCount(Long id) { + return brands.containsKey(id) ? 1 : 0; + } + + @Override + public int decreaseLikeCount(Long id) { + return brands.containsKey(id) ? 1 : 0; + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index c20fdc2f2..95bf16514 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -82,7 +82,7 @@ void updatesBrand_whenFieldsAreValid() { Brand brand = Brand.create("Nike", "Just Do It"); // Act - brand.update("Adidas", "Impossible Is Nothing"); + brand.updateInfo("Adidas", "Impossible Is Nothing"); // Assert assertAll( @@ -99,7 +99,7 @@ void throwsBadRequest_whenNameIsNull() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - brand.update(null, "설명"); + brand.updateInfo(null, "설명"); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @@ -112,7 +112,7 @@ void throwsBadRequest_whenNameIsBlank() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - brand.update(" ", "설명"); + brand.updateInfo(" ", "설명"); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java new file mode 100644 index 000000000..fd597badb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java @@ -0,0 +1,558 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponServiceTest { + + private CouponService couponService; + private FakeCouponReader fakeCouponReader; + private FakeCouponRepository fakeCouponRepository; + private FakeMemberCouponReader fakeMemberCouponReader; + private FakeMemberCouponRepository fakeMemberCouponRepository; + + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + private static final LocalDateTime PAST = LocalDateTime.now().minusDays(1); + + @BeforeEach + void setUp() { + fakeCouponReader = new FakeCouponReader(); + fakeCouponRepository = new FakeCouponRepository(fakeCouponReader); + fakeMemberCouponReader = new FakeMemberCouponReader(); + fakeMemberCouponRepository = new FakeMemberCouponRepository(fakeMemberCouponReader); + couponService = new CouponService( + fakeCouponRepository, fakeCouponReader, + fakeMemberCouponRepository, fakeMemberCouponReader + ); + } + + @DisplayName("쿠폰 템플릿을 생성할 때, ") + @Nested + class CreateCoupon { + + @DisplayName("유효한 정보면, 쿠폰이 저장된다.") + @Test + void savesCoupon_whenFieldsAreValid() { + // Act + Coupon coupon = couponService.createCoupon("신규가입 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Assert + assertAll( + () -> assertThat(coupon.getName()).isEqualTo("신규가입 쿠폰"), + () -> assertThat(coupon.getType()).isEqualTo(CouponType.FIXED), + () -> assertThat(coupon.getValue()).isEqualTo(5000L) + ); + } + } + + @DisplayName("쿠폰 템플릿을 조회할 때, ") + @Nested + class GetCoupon { + + @DisplayName("존재하는 쿠폰이면, 쿠폰을 반환한다.") + @Test + void returnsCoupon_whenCouponExists() { + // Arrange + Coupon coupon = couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Act + Coupon found = couponService.getCoupon(1L); + + // Assert + assertThat(found.getName()).isEqualTo("쿠폰"); + } + + @DisplayName("존재하지 않는 쿠폰이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.getCoupon(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("쿠폰 목록을 조회할 때, ") + @Nested + class GetCoupons { + + @DisplayName("쿠폰이 존재하면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenCouponsExist() { + // Arrange + couponService.createCoupon("쿠폰1", CouponType.FIXED, 5000L, null, FUTURE); + couponService.createCoupon("쿠폰2", CouponType.RATE, 10L, null, FUTURE); + + // Act + PageResult result = couponService.getCoupons(0, 20); + + // Assert + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.page()).isZero(), + () -> assertThat(result.size()).isEqualTo(20) + ); + } + } + + @DisplayName("쿠폰 템플릿을 수정할 때, ") + @Nested + class UpdateCoupon { + + @DisplayName("유효한 정보로 수정하면, 쿠폰이 수정된다.") + @Test + void updatesCoupon_whenFieldsAreValid() { + // Arrange + couponService.createCoupon("기존 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + LocalDateTime newExpiredAt = FUTURE.plusDays(10); + + // Act + couponService.updateCoupon(1L, "수정된 쿠폰", 3000L, 20000L, newExpiredAt, null); + + // Assert + Coupon updated = couponService.getCoupon(1L); + assertAll( + () -> assertThat(updated.getName()).isEqualTo("수정된 쿠폰"), + () -> assertThat(updated.getValue()).isEqualTo(3000L), + () -> assertThat(updated.getMinOrderAmount()).isEqualTo(20000L) + ); + } + + @DisplayName("존재하지 않는 쿠폰이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.updateCoupon(999L, "이름", 3000L, null, FUTURE, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("쿠폰 템플릿을 삭제할 때, ") + @Nested + class DeleteCoupon { + + @DisplayName("존재하는 쿠폰이면, soft delete 처리된다.") + @Test + void deletesCoupon_whenCouponExists() { + // Arrange + Coupon coupon = couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Act + couponService.deleteCoupon(1L); + + // Assert + assertThat(coupon.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 쿠폰이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.deleteCoupon(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("쿠폰을 발급할 때, ") + @Nested + class IssueCoupon { + + @DisplayName("유효한 쿠폰이면, MemberCoupon이 생성된다.") + @Test + void createsMemberCoupon_whenCouponIsValid() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Act + MemberCoupon memberCoupon = couponService.issueCoupon(1L, 100L); + + // Assert + assertAll( + () -> assertThat(memberCoupon.getMemberId()).isEqualTo(100L), + () -> assertThat(memberCoupon.getCouponId()).isEqualTo(1L), + () -> assertThat(memberCoupon.getStatus()).isEqualTo(CouponStatus.AVAILABLE) + ); + } + + @DisplayName("존재하지 않는 쿠폰이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.issueCoupon(999L, 100L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("만료된 쿠폰이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCouponIsExpired() { + // Arrange + couponService.createCoupon("만료 쿠폰", CouponType.FIXED, 5000L, null, PAST); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.issueCoupon(1L, 100L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이미 발급받은 쿠폰이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenAlreadyIssued() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.issueCoupon(1L, 100L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("validDays 쿠폰이면, 발급 시점 + validDays로 개인 만료일이 설정된다.") + @Test + void setsMemberExpiredAt_whenValidDaysIsSet() { + // Arrange — validDays=7인 쿠폰 + couponService.createCoupon("기간제 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, 7, null); + + // Act + MemberCoupon mc = couponService.issueCoupon(1L, 100L); + + // Assert — 개인 만료일이 쿠폰 expiredAt이 아닌 발급시점+7일 근처 + LocalDateTime expectedAround = LocalDateTime.now().plusDays(7); + assertThat(mc.getExpiredAt()).isBefore(FUTURE); // 쿠폰 만료일보다 이전 + assertThat(mc.getExpiredAt()).isAfter(expectedAround.minusMinutes(1)); + assertThat(mc.getExpiredAt()).isBefore(expectedAround.plusMinutes(1)); + } + + @DisplayName("validDays가 없으면, 쿠폰 만료일이 개인 만료일로 설정된다.") + @Test + void setsCouponExpiredAt_whenValidDaysIsNull() { + // Arrange + couponService.createCoupon("일반 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Act + MemberCoupon mc = couponService.issueCoupon(1L, 100L); + + // Assert + assertThat(mc.getExpiredAt()).isEqualTo(FUTURE); + } + + @DisplayName("수량 제한 쿠폰의 수량이 소진되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityExhausted() { + // Arrange - 수량 1개짜리 쿠폰 + couponService.createCoupon("한정 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, 1); + couponService.issueCoupon(1L, 100L); // 1/1 소진 + + // Act & Assert - 다른 사용자가 발급 시도 + CoreException exception = assertThrows(CoreException.class, () -> + couponService.issueCoupon(1L, 200L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("사용자 쿠폰 목록을 조회할 때, ") + @Nested + class GetMemberCoupons { + + @DisplayName("발급된 쿠폰이 있으면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenMemberCouponsExist() { + // Arrange + couponService.createCoupon("쿠폰1", CouponType.FIXED, 5000L, null, FUTURE); + couponService.createCoupon("쿠폰2", CouponType.RATE, 10L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + couponService.issueCoupon(2L, 100L); + + // Act + PageResult result = couponService.getMemberCoupons(100L, 0, 20); + + // Assert + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.page()).isZero() + ); + } + } + + @DisplayName("쿠폰 발급 내역을 조회할 때, ") + @Nested + class GetIssuedCoupons { + + @DisplayName("발급 내역이 있으면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenIssuedCouponsExist() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + couponService.issueCoupon(1L, 200L); + + // Act + PageResult result = couponService.getIssuedCoupons(1L, 0, 20); + + // Assert + assertThat(result.content()).hasSize(2); + } + } + + @DisplayName("쿠폰을 사용할 때, ") + @Nested + class UseCoupon { + + @DisplayName("유효한 쿠폰이면, 할인액을 반환하고 USED 상태로 변경된다.") + @Test + void returnsCouponApplyResult_whenValid() { + // Arrange + couponService.createCoupon("5000원 할인", CouponType.FIXED, 5000L, null, FUTURE); + MemberCoupon mc = couponService.issueCoupon(1L, 100L); + + // Act + CouponService.CouponApplyResult result = couponService.useCoupon(1L, 100L, 50000L); + + // Assert + assertAll( + () -> assertThat(result.discountAmount()).isEqualTo(5000L), + () -> assertThat(mc.getStatus()).isEqualTo(CouponStatus.USED), + () -> assertThat(mc.getUsedAt()).isNotNull() + ); + } + + @DisplayName("정률 쿠폰이면, 주문 금액 기준으로 할인액을 계산한다.") + @Test + void calculatesRateDiscount_whenRateCoupon() { + // Arrange + couponService.createCoupon("10% 할인", CouponType.RATE, 10L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + + // Act + CouponService.CouponApplyResult result = couponService.useCoupon(1L, 100L, 80000L); + + // Assert + assertThat(result.discountAmount()).isEqualTo(8000L); + } + + @DisplayName("존재하지 않는 memberCouponId이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenMemberCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.useCoupon(999L, 100L, 50000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("다른 사용자의 쿠폰이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNotOwner() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.useCoupon(1L, 200L, 50000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("MemberCoupon이 만료되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberCouponExpired() { + // Arrange — 쿠폰 자체는 유효하지만 개인 만료일이 지난 상태 + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + fakeCouponReader.addCoupon(1L, coupon); + MemberCoupon mc = MemberCoupon.create(100L, 1L, PAST); // 개인 만료일이 과거 + fakeMemberCouponReader.addMemberCoupon(1L, mc); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.useCoupon(1L, 100L, 50000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이미 사용된 쿠폰이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyUsed() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + couponService.useCoupon(1L, 100L, 50000L); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.useCoupon(1L, 100L, 50000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최소 주문 금액 미달이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBelowMinOrderAmount() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, 50000L, FUTURE); + couponService.issueCoupon(1L, 100L); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + couponService.useCoupon(1L, 100L, 30000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰을 복원할 때, ") + @Nested + class RestoreCoupon { + + @DisplayName("사용된 쿠폰이면, AVAILABLE로 복원된다.") + @Test + void restoresCoupon_whenUsed() { + // Arrange + couponService.createCoupon("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + couponService.issueCoupon(1L, 100L); + couponService.useCoupon(1L, 100L, 50000L); + + // Act + couponService.restoreCoupon(1L); + + // Assert + MemberCoupon mc = couponService.getMemberCoupon(1L); + assertAll( + () -> assertThat(mc.getStatus()).isEqualTo(CouponStatus.AVAILABLE), + () -> assertThat(mc.getUsedAt()).isNull() + ); + } + + @DisplayName("존재하지 않는 memberCouponId이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenMemberCouponNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + couponService.restoreCoupon(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + // ── Fake 구현체 ── + + static class FakeCouponReader implements CouponReader { + private final Map coupons = new HashMap<>(); + + void addCoupon(Long id, Coupon coupon) { + coupons.put(id, coupon); + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(coupons.get(id)); + } + + @Override + public Optional findByIdForUpdate(Long id) { + return Optional.ofNullable(coupons.get(id)); + } + + @Override + public List findAllByIdIn(List ids) { + return ids.stream() + .map(coupons::get) + .filter(java.util.Objects::nonNull) + .toList(); + } + + @Override + public PageResult findAll(int page, int size) { + List all = new ArrayList<>(coupons.values()); + return new PageResult<>(all, all.size(), 1, page, size); + } + } + + static class FakeCouponRepository implements CouponRepository { + private final FakeCouponReader fakeCouponReader; + private long idSequence = 1L; + + FakeCouponRepository(FakeCouponReader fakeCouponReader) { + this.fakeCouponReader = fakeCouponReader; + } + + @Override + public Coupon save(Coupon coupon) { + long id = idSequence++; + fakeCouponReader.addCoupon(id, coupon); + return coupon; + } + } + + static class FakeMemberCouponReader implements MemberCouponReader { + private final Map memberCoupons = new HashMap<>(); + + void addMemberCoupon(Long id, MemberCoupon memberCoupon) { + memberCoupons.put(id, memberCoupon); + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(memberCoupons.get(id)); + } + + @Override + public Optional findByMemberIdAndCouponId(Long memberId, Long couponId) { + return memberCoupons.values().stream() + .filter(mc -> mc.getMemberId().equals(memberId) && mc.getCouponId().equals(couponId)) + .findFirst(); + } + + @Override + public PageResult findAllByMemberId(Long memberId, int page, int size) { + List filtered = memberCoupons.values().stream() + .filter(mc -> mc.getMemberId().equals(memberId)) + .toList(); + return new PageResult<>(filtered, filtered.size(), 1, page, size); + } + + @Override + public PageResult findAllByCouponId(Long couponId, int page, int size) { + List filtered = memberCoupons.values().stream() + .filter(mc -> mc.getCouponId().equals(couponId)) + .toList(); + return new PageResult<>(filtered, filtered.size(), 1, page, size); + } + } + + static class FakeMemberCouponRepository implements MemberCouponRepository { + private final FakeMemberCouponReader fakeMemberCouponReader; + private long idSequence = 1L; + + FakeMemberCouponRepository(FakeMemberCouponReader fakeMemberCouponReader) { + this.fakeMemberCouponReader = fakeMemberCouponReader; + } + + @Override + public MemberCoupon save(MemberCoupon memberCoupon) { + long id = idSequence++; + fakeMemberCouponReader.addMemberCoupon(id, memberCoupon); + return memberCoupon; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java new file mode 100644 index 000000000..f2066e349 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java @@ -0,0 +1,345 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponTest { + + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + private static final LocalDateTime PAST = LocalDateTime.now().minusDays(1); + + @DisplayName("쿠폰을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsCoupon_whenAllFieldsAreValid() { + // Arrange & Act + Coupon coupon = Coupon.create("신규가입 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + // Assert + assertAll( + () -> assertThat(coupon.getName()).isEqualTo("신규가입 쿠폰"), + () -> assertThat(coupon.getType()).isEqualTo(CouponType.FIXED), + () -> assertThat(coupon.getValue()).isEqualTo(5000L), + () -> assertThat(coupon.getMinOrderAmount()).isNull(), + () -> assertThat(coupon.getExpiredAt()).isEqualTo(FUTURE) + ); + } + + @DisplayName("최소 주문 금액이 설정되면, 정상적으로 생성된다.") + @Test + void createsCoupon_whenMinOrderAmountIsSet() { + // Arrange & Act + Coupon coupon = Coupon.create("10% 할인", CouponType.RATE, 10L, 30000L, FUTURE); + + // Assert + assertAll( + () -> assertThat(coupon.getType()).isEqualTo(CouponType.RATE), + () -> assertThat(coupon.getValue()).isEqualTo(10L), + () -> assertThat(coupon.getMinOrderAmount()).isEqualTo(30000L) + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create(null, CouponType.FIXED, 5000L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create(" ", CouponType.FIXED, 5000L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("타입이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTypeIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create("쿠폰", null, 5000L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("할인 값이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create("쿠폰", CouponType.FIXED, 0L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("할인 값이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create("쿠폰", CouponType.FIXED, -1000L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExpiredAtIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("정률 쿠폰의 할인율이 100을 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenRateExceeds100() { + CoreException exception = assertThrows(CoreException.class, () -> + Coupon.create("쿠폰", CouponType.RATE, 101L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("정률 쿠폰의 할인율이 100이면, 정상적으로 생성된다.") + @Test + void createsCoupon_whenRateIs100() { + Coupon coupon = Coupon.create("100% 할인", CouponType.RATE, 100L, null, FUTURE); + assertThat(coupon.getValue()).isEqualTo(100L); + } + + @DisplayName("validDays가 설정되면, 정상적으로 생성된다.") + @Test + void createsCoupon_whenValidDaysIsSet() { + Coupon coupon = Coupon.create("기간제 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, 7, null); + + assertAll( + () -> assertThat(coupon.getValidDays()).isEqualTo(7), + () -> assertThat(coupon.getTotalQuantity()).isNull() + ); + } + + @DisplayName("validDays가 null이면, 기본 동작으로 생성된다.") + @Test + void createsCoupon_whenValidDaysIsNull() { + Coupon coupon = Coupon.create("일반 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + assertThat(coupon.getValidDays()).isNull(); + } + } + + @DisplayName("쿠폰을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 정상적으로 수정된다.") + @Test + void updatesCoupon_whenFieldsAreValid() { + // Arrange + Coupon coupon = Coupon.create("기존 쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + LocalDateTime newExpiredAt = FUTURE.plusDays(10); + + // Act + coupon.updateInfo("수정된 쿠폰", 3000L, 20000L, newExpiredAt, 14); + + // Assert + assertAll( + () -> assertThat(coupon.getName()).isEqualTo("수정된 쿠폰"), + () -> assertThat(coupon.getValue()).isEqualTo(3000L), + () -> assertThat(coupon.getMinOrderAmount()).isEqualTo(20000L), + () -> assertThat(coupon.getExpiredAt()).isEqualTo(newExpiredAt), + () -> assertThat(coupon.getValidDays()).isEqualTo(14), + () -> assertThat(coupon.getType()).isEqualTo(CouponType.FIXED) // type 변경 불가 + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + CoreException exception = assertThrows(CoreException.class, () -> + coupon.updateInfo(null, 3000L, null, FUTURE, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("할인 값이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + + CoreException exception = assertThrows(CoreException.class, () -> + coupon.updateInfo("쿠폰", 0L, null, FUTURE, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("정률 쿠폰 수정 시 할인율이 100을 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenRateUpdateExceeds100() { + Coupon coupon = Coupon.create("쿠폰", CouponType.RATE, 10L, null, FUTURE); + + CoreException exception = assertThrows(CoreException.class, () -> + coupon.updateInfo("쿠폰", 101L, null, FUTURE, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("할인 금액을 계산할 때, ") + @Nested + class CalculateDiscount { + + @DisplayName("정액 쿠폰이면, 할인 값 그대로 반환한다.") + @Test + void returnsFixedValue_whenTypeIsFixed() { + // Arrange + Coupon coupon = Coupon.create("5000원 할인", CouponType.FIXED, 5000L, null, FUTURE); + + // Act + long discount = coupon.calculateDiscount(50000L); + + // Assert + assertThat(discount).isEqualTo(5000L); + } + + @DisplayName("정액 쿠폰의 할인 값이 주문 금액을 초과하면, 주문 금액을 반환한다.") + @Test + void returnsOrderAmount_whenFixedValueExceedsOrderAmount() { + // Arrange + Coupon coupon = Coupon.create("10000원 할인", CouponType.FIXED, 10000L, null, FUTURE); + + // Act + long discount = coupon.calculateDiscount(8000L); + + // Assert + assertThat(discount).isEqualTo(8000L); + } + + @DisplayName("정률 쿠폰이면, 주문 금액의 비율만큼 반환한다.") + @Test + void returnsPercentage_whenTypeIsRate() { + // Arrange + Coupon coupon = Coupon.create("10% 할인", CouponType.RATE, 10L, null, FUTURE); + + // Act + long discount = coupon.calculateDiscount(50000L); + + // Assert + assertThat(discount).isEqualTo(5000L); + } + + @DisplayName("정률 쿠폰 100%이면, 주문 금액 전액을 반환한다.") + @Test + void returnsFullAmount_whenRateIs100() { + // Arrange + Coupon coupon = Coupon.create("100% 할인", CouponType.RATE, 100L, null, FUTURE); + + // Act + long discount = coupon.calculateDiscount(50000L); + + // Assert + assertThat(discount).isEqualTo(50000L); + } + } + + @DisplayName("만료 여부를 확인할 때, ") + @Nested + class IsExpired { + + @DisplayName("만료일이 현재보다 미래이면, false를 반환한다.") + @Test + void returnsFalse_whenNotExpired() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + assertThat(coupon.isExpired()).isFalse(); + } + + @DisplayName("만료일이 현재보다 과거이면, true를 반환한다.") + @Test + void returnsTrue_whenExpired() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, PAST); + assertThat(coupon.isExpired()).isTrue(); + } + } + + @DisplayName("쿠폰을 발급(수량 차감)할 때, ") + @Nested + class IssueOne { + + @DisplayName("수량 제한이 없으면(totalQuantity == null), 발급이 성공한다.") + @Test + void issuesSuccessfully_whenNoQuantityLimit() { + Coupon coupon = Coupon.create("무제한 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, null); + + coupon.issueOne(); + + assertThat(coupon.getIssuedQuantity()).isEqualTo(1); + } + + @DisplayName("수량이 남아있으면, 발급이 성공하고 issuedQuantity가 증가한다.") + @Test + void issuesSuccessfully_whenQuantityRemains() { + Coupon coupon = Coupon.create("한정 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, 100); + + coupon.issueOne(); + + assertAll( + () -> assertThat(coupon.getIssuedQuantity()).isEqualTo(1), + () -> assertThat(coupon.getTotalQuantity()).isEqualTo(100) + ); + } + + @DisplayName("수량이 모두 소진되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityExhausted() { + Coupon coupon = Coupon.create("한정 쿠폰", CouponType.FIXED, 5000L, null, FUTURE, 1); + coupon.issueOne(); // 1/1 소진 + + CoreException exception = assertThrows(CoreException.class, coupon::issueOne); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("최소 주문 금액을 검증할 때, ") + @Nested + class ValidateMinOrderAmount { + + @DisplayName("최소 주문 금액 조건이 없으면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenNoMinOrderAmount() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, null, FUTURE); + coupon.validateMinOrderAmount(1000L); + } + + @DisplayName("주문 금액이 최소 주문 금액 이상이면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenOrderAmountMeetsMinimum() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, 30000L, FUTURE); + coupon.validateMinOrderAmount(30000L); + } + + @DisplayName("주문 금액이 최소 주문 금액 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderAmountBelowMinimum() { + Coupon coupon = Coupon.create("쿠폰", CouponType.FIXED, 5000L, 30000L, FUTURE); + + CoreException exception = assertThrows(CoreException.class, () -> + coupon.validateMinOrderAmount(29999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/MemberCouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/MemberCouponTest.java new file mode 100644 index 000000000..30dbbd20a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/MemberCouponTest.java @@ -0,0 +1,271 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberCouponTest { + + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + private static final LocalDateTime PAST = LocalDateTime.now().minusDays(1); + + @DisplayName("사용자 쿠폰을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이면, AVAILABLE 상태로 생성되고 만료일이 설정된다.") + @Test + void createsMemberCoupon_withAvailableStatus() { + // Arrange & Act + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + + // Assert + assertAll( + () -> assertThat(memberCoupon.getMemberId()).isEqualTo(1L), + () -> assertThat(memberCoupon.getCouponId()).isEqualTo(100L), + () -> assertThat(memberCoupon.getStatus()).isEqualTo(CouponStatus.AVAILABLE), + () -> assertThat(memberCoupon.getUsedAt()).isNull(), + () -> assertThat(memberCoupon.getExpiredAt()).isEqualTo(FUTURE) + ); + } + + @DisplayName("회원 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberIdIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + MemberCoupon.create(null, 100L, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("쿠폰 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCouponIdIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + MemberCoupon.create(1L, null, FUTURE) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료일이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExpiredAtIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + MemberCoupon.create(1L, 100L, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰 만료 여부를 확인할 때, ") + @Nested + class IsExpired { + + @DisplayName("만료일이 미래이면, false를 반환한다.") + @Test + void returnsFalse_whenExpiredAtIsFuture() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + assertThat(memberCoupon.isExpired()).isFalse(); + } + + @DisplayName("만료일이 과거이면, true를 반환한다.") + @Test + void returnsTrue_whenExpiredAtIsPast() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, PAST); + assertThat(memberCoupon.isExpired()).isTrue(); + } + } + + @DisplayName("쿠폰을 사용할 때, ") + @Nested + class Use { + + @DisplayName("AVAILABLE 상태이면, USED로 변경되고 사용 시간이 기록된다.") + @Test + void changesStatusToUsed_whenAvailable() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + + // Act + memberCoupon.use(); + + // Assert + assertAll( + () -> assertThat(memberCoupon.getStatus()).isEqualTo(CouponStatus.USED), + () -> assertThat(memberCoupon.getUsedAt()).isNotNull() + ); + } + + @DisplayName("이미 USED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyUsed() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.use(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, memberCoupon::use); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("EXPIRED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExpired() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.expire(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, memberCoupon::use); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("만료일이 지난 쿠폰이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExpiredByTime() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, PAST); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, memberCoupon::use); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰을 만료시킬 때, ") + @Nested + class Expire { + + @DisplayName("AVAILABLE 상태이면, EXPIRED로 변경된다.") + @Test + void changesStatusToExpired_whenAvailable() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + + // Act + memberCoupon.expire(); + + // Assert + assertThat(memberCoupon.getStatus()).isEqualTo(CouponStatus.EXPIRED); + } + + @DisplayName("이미 USED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyUsed() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.use(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, memberCoupon::expire); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소유자를 검증할 때, ") + @Nested + class ValidateOwner { + + @DisplayName("소유자가 일치하면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenOwnerMatches() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.validateOwner(1L); + } + + @DisplayName("소유자가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOwnerDoesNotMatch() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + + CoreException exception = assertThrows(CoreException.class, () -> + memberCoupon.validateOwner(2L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("쿠폰을 복원할 때, ") + @Nested + class Restore { + + @DisplayName("USED 상태이면, AVAILABLE로 변경되고 사용 시간이 초기화된다.") + @Test + void changesStatusToAvailable_whenUsed() { + // Arrange + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.use(); + + // Act + memberCoupon.restore(); + + // Assert + assertAll( + () -> assertThat(memberCoupon.getStatus()).isEqualTo(CouponStatus.AVAILABLE), + () -> assertThat(memberCoupon.getUsedAt()).isNull() + ); + } + + @DisplayName("AVAILABLE 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAvailable() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + + CoreException exception = assertThrows(CoreException.class, memberCoupon::restore); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("EXPIRED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExpired() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.expire(); + + CoreException exception = assertThrows(CoreException.class, memberCoupon::restore); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("사용 가능 여부를 확인할 때, ") + @Nested + class IsUsable { + + @DisplayName("AVAILABLE 상태이면, true를 반환한다.") + @Test + void returnsTrue_whenAvailable() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + assertThat(memberCoupon.isUsable()).isTrue(); + } + + @DisplayName("USED 상태이면, false를 반환한다.") + @Test + void returnsFalse_whenUsed() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.use(); + assertThat(memberCoupon.isUsable()).isFalse(); + } + + @DisplayName("EXPIRED 상태이면, false를 반환한다.") + @Test + void returnsFalse_whenExpired() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, FUTURE); + memberCoupon.expire(); + assertThat(memberCoupon.isUsable()).isFalse(); + } + + @DisplayName("AVAILABLE 상태이지만 만료일이 지났으면, false를 반환한다.") + @Test + void returnsFalse_whenAvailableButExpiredByTime() { + MemberCoupon memberCoupon = MemberCoupon.create(1L, 100L, PAST); + assertThat(memberCoupon.isUsable()).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 94b22b3c7..1bf3a6946 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -216,12 +216,12 @@ void cancelsOrder_andReturnsItems() { fakeOrderItemReader.addItem(item); // Act - List items = orderService.cancelOrder(order.getId(), 1L); + OrderService.CancelOrderResult result = orderService.cancelOrder(order.getId(), 1L); // Assert assertAll( - () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED), - () -> assertThat(items).hasSize(1) + () -> assertThat(result.order().getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> assertThat(result.items()).hasSize(1) ); } @@ -319,6 +319,11 @@ public Optional findByIdAndMemberId(Long id, Long memberId) { .findFirst(); } + @Override + public Optional findByIdAndMemberIdForUpdate(Long id, Long memberId) { + return findByIdAndMemberId(id, memberId); + } + @Override public PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size) { List filtered = orders.stream() diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index d90eafb91..3fdd810c9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -77,6 +77,71 @@ void throwsBadRequest_whenTotalAmountIsNotPositive() { ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + + @DisplayName("쿠폰을 적용한 주문이면, 쿠폰 관련 필드가 설정된다.") + @Test + void createsOrder_withCouponFields() { + // Arrange & Act + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", + "서울시 강남구", null, 95000L, + 10L, 100000L, 5000L + ); + + // Assert + assertAll( + () -> assertThat(order.getTotalAmount()).isEqualTo(95000L), + () -> assertThat(order.getMemberCouponId()).isEqualTo(10L), + () -> assertThat(order.getOriginalAmount()).isEqualTo(100000L), + () -> assertThat(order.getDiscountAmount()).isEqualTo(5000L) + ); + } + + @DisplayName("쿠폰 적용 시 originalAmount가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCouponAppliedButOriginalAmountIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, + 95000L, 10L, null, 5000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("쿠폰 적용 시 discountAmount가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCouponAppliedButDiscountAmountIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, + 95000L, 10L, 100000L, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("쿠폰 적용 시 totalAmount가 originalAmount - discountAmount와 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountMismatchWithDiscount() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, + 90000L, 10L, 100000L, 5000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("쿠폰 미적용 주문이면, 쿠폰 관련 필드가 null이다.") + @Test + void createsOrder_withoutCouponFields() { + // Arrange & Act + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L + ); + + // Assert + assertAll( + () -> assertThat(order.getMemberCouponId()).isNull(), + () -> assertThat(order.getOriginalAmount()).isNull(), + () -> assertThat(order.getDiscountAmount()).isNull() + ); + } } @DisplayName("주문을 취소할 때, ") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index a9e22d17b..1dd73d603 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -93,7 +93,7 @@ void updatesProduct_whenFieldsAreValid() { fakeProductReader.addProduct(1L, product); // Act - productService.update(1L, "에어맥스 95", "수정된 설명", 149000L, 3); + productService.updateInfo(1L, "에어맥스 95", "수정된 설명", 149000L, 3); // Assert assertAll( @@ -109,10 +109,24 @@ void updatesProduct_whenFieldsAreValid() { void throwsNotFound_whenProductNotExists() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - productService.update(999L, "이름", "설명", 10000L, 5) + productService.updateInfo(999L, "이름", "설명", 10000L, 5) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } + + @DisplayName("수정 시 비관적 락(findByIdForUpdate)을 통해 상품을 조회한다.") + @Test + void usesForUpdateLock_whenUpdatingProduct() { + // Arrange + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + fakeProductReader.addProduct(1L, product); + + // Act + productService.updateInfo(1L, "에어맥스 95", "수정된 설명", 149000L, 3); + + // Assert + assertThat(fakeProductReader.isFindByIdForUpdateCalled()).isTrue(); + } } @DisplayName("상품을 삭제할 때, ") @@ -232,6 +246,7 @@ static class FakeProductReader implements ProductReader { private final Map products = new HashMap<>(); private List allProducts = List.of(); private final Map> productsByBrandId = new HashMap<>(); + private boolean findByIdForUpdateCalled = false; void addProduct(Long id, Product product) { products.put(id, product); @@ -245,11 +260,21 @@ void addProductsByBrandId(Long brandId, List products) { this.productsByBrandId.put(brandId, products); } + boolean isFindByIdForUpdateCalled() { + return findByIdForUpdateCalled; + } + @Override public Optional findById(Long id) { return Optional.ofNullable(products.get(id)); } + @Override + public Optional findByIdForUpdate(Long id) { + findByIdForUpdateCalled = true; + return Optional.ofNullable(products.get(id)); + } + @Override public PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size) { return new PageResult<>(allProducts, allProducts.size(), 1, page, size); @@ -285,5 +310,15 @@ public Product save(Product product) { fakeProductReader.addProduct(id, product); return product; } + + @Override + public int increaseLikeCount(Long id) { + return 1; + } + + @Override + public int decreaseLikeCount(Long id) { + return 1; + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 7667f10e2..4a59d74a5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -136,7 +136,7 @@ void updatesProduct_whenFieldsAreValid() { Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); // Act - product.update("에어맥스 95", "업데이트된 설명", 149000L, 3); + product.updateInfo("에어맥스 95", "업데이트된 설명", 149000L, 3); // Assert assertAll( @@ -156,7 +156,7 @@ void throwsBadRequest_whenNameIsNull() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - product.update(null, "설명", 149000L, 3) + product.updateInfo(null, "설명", 149000L, 3) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @@ -169,7 +169,7 @@ void throwsBadRequest_whenPriceIsZero() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - product.update("에어맥스 95", "설명", 0L, 3) + product.updateInfo("에어맥스 95", "설명", 0L, 3) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @@ -182,7 +182,7 @@ void throwsBadRequest_whenMaxOrderQuantityIsZero() { // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> - product.update("에어맥스 95", "설명", 149000L, 0) + product.updateInfo("에어맥스 95", "설명", 149000L, 0) ); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductJpaRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductJpaRepositoryTest.java new file mode 100644 index 000000000..6c969dbba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductJpaRepositoryTest.java @@ -0,0 +1,133 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ProductJpaRepositoryTest { + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Product createAndSaveProduct() { + Product product = Product.create(1L, "테스트 상품", "설명", 10000L, 100, 5); + return productJpaRepository.save(product); + } + + @DisplayName("increaseLikeCount") + @Nested + class IncreaseLikeCount { + + @DisplayName("soft-delete된 상품은 좋아요 수가 증가하지 않는다.") + @Test + @Transactional + void doesNotIncrease_whenProductIsSoftDeleted() { + // arrange + Product product = createAndSaveProduct(); + product.delete(); + productJpaRepository.saveAndFlush(product); + + // act + int updatedRows = productJpaRepository.increaseLikeCount(product.getId()); + + // assert + assertThat(updatedRows).isZero(); + } + + @DisplayName("존재하지 않는 상품 ID는 좋아요 수가 증가하지 않는다.") + @Test + @Transactional + void doesNotIncrease_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + int updatedRows = productJpaRepository.increaseLikeCount(nonExistentId); + + // assert + assertThat(updatedRows).isZero(); + } + + @DisplayName("정상 상품은 좋아요 수가 증가한다.") + @Test + @Transactional + void increases_whenProductExists() { + // arrange + Product product = createAndSaveProduct(); + + // act + int updatedRows = productJpaRepository.increaseLikeCount(product.getId()); + + // assert + assertThat(updatedRows).isEqualTo(1); + } + } + + @DisplayName("decreaseLikeCount") + @Nested + class DecreaseLikeCount { + + @DisplayName("soft-delete된 상품은 좋아요 수가 감소하지 않는다.") + @Test + @Transactional + void doesNotDecrease_whenProductIsSoftDeleted() { + // arrange + Product product = createAndSaveProduct(); + productJpaRepository.increaseLikeCount(product.getId()); + product.delete(); + productJpaRepository.saveAndFlush(product); + + // act + int updatedRows = productJpaRepository.decreaseLikeCount(product.getId()); + + // assert + assertThat(updatedRows).isZero(); + } + + @DisplayName("존재하지 않는 상품 ID는 좋아요 수가 감소하지 않는다.") + @Test + @Transactional + void doesNotDecrease_whenProductDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + int updatedRows = productJpaRepository.decreaseLikeCount(nonExistentId); + + // assert + assertThat(updatedRows).isZero(); + } + + @DisplayName("정상 상품은 좋아요 수가 감소한다.") + @Test + @Transactional + void decreases_whenProductExistsAndLikeCountIsPositive() { + // arrange + Product product = createAndSaveProduct(); + productJpaRepository.increaseLikeCount(product.getId()); + + // act + int updatedRows = productJpaRepository.decreaseLikeCount(product.getId()); + + // assert + assertThat(updatedRows).isEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponAdminV1ApiE2ETest.java new file mode 100644 index 000000000..e466d4835 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponAdminV1ApiE2ETest.java @@ -0,0 +1,375 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.coupon.CouponV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CouponAdminV1ApiE2ETest { + + private static final String COUPON_ADMIN = "/api-admin/v1/coupons"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public CouponAdminV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api-admin/v1/coupons (쿠폰 등록)") + @Nested + class CreateCoupon { + + @DisplayName("정액 쿠폰을 등록하면, 201 Created 응답을 받는다.") + @Test + void returnsCreated_whenFixedCoupon() { + // Arrange + var request = new CouponV1Dto.CreateCouponRequest("5000원 할인", "FIXED", 5000L, null, FUTURE, null, null); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().coupon().name()).isEqualTo("5000원 할인"), + () -> assertThat(response.getBody().data().coupon().type()).isEqualTo("FIXED"), + () -> assertThat(response.getBody().data().coupon().value()).isEqualTo(5000L) + ); + } + + @DisplayName("정률 쿠폰을 등록하면, 201 Created 응답을 받는다.") + @Test + void returnsCreated_whenRateCoupon() { + // Arrange + var request = new CouponV1Dto.CreateCouponRequest("10% 할인", "RATE", 10L, 30000L, FUTURE, null, null); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().coupon().type()).isEqualTo("RATE"), + () -> assertThat(response.getBody().data().coupon().value()).isEqualTo(10L), + () -> assertThat(response.getBody().data().coupon().minOrderAmount()).isEqualTo(30000L) + ); + } + + @DisplayName("이름이 빈 문자열이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + var request = new CouponV1Dto.CreateCouponRequest(" ", "FIXED", 5000L, null, FUTURE, null, null); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("수량 제한 쿠폰을 등록하면, totalQuantity와 issuedQuantity가 응답에 포함된다.") + @Test + void returnsCreated_whenLimitedCoupon() { + // Arrange + var request = new CouponV1Dto.CreateCouponRequest("선착순 쿠폰", "FIXED", 3000L, null, FUTURE, null, 100); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().coupon().totalQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().coupon().issuedQuantity()).isEqualTo(0) + ); + } + + @DisplayName("validDays가 설정된 기간제 쿠폰을 등록하면, 응답에 validDays가 포함된다.") + @Test + void returnsCreated_whenValidDaysCoupon() { + // Arrange + var request = new CouponV1Dto.CreateCouponRequest("기간제 쿠폰", "FIXED", 5000L, null, FUTURE, 7, null); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().coupon().validDays()).isEqualTo(7), + () -> assertThat(response.getBody().data().coupon().totalQuantity()).isNull() + ); + } + + @DisplayName("존재하지 않는 쿠폰 타입이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenInvalidType() { + var request = new CouponV1Dto.CreateCouponRequest("쿠폰", "INVALID_TYPE", 5000L, null, FUTURE, null, null); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("정률 쿠폰의 할인율이 100을 초과하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenRateExceeds100() { + var request = new CouponV1Dto.CreateCouponRequest("쿠폰", "RATE", 101L, null, FUTURE, null, null); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/coupons (쿠폰 목록 조회)") + @Nested + class GetCoupons { + + @DisplayName("쿠폰이 존재하면, 목록을 반환한다.") + @Test + void returnsCoupons_whenCouponsExist() { + // Arrange + createCoupon("쿠폰1", "FIXED", 5000L); + createCoupon("쿠폰2", "RATE", 10L); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().coupons()).hasSize(2) + ); + } + } + + @DisplayName("GET /api-admin/v1/coupons/{couponId} (쿠폰 상세 조회)") + @Nested + class GetCoupon { + + @DisplayName("존재하는 쿠폰을 조회하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenCouponExists() { + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().coupon().name()).isEqualTo("5000원 할인") + ); + } + + @DisplayName("존재하지 않는 쿠폰을 조회하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenCouponNotExists() { + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/coupons/{couponId} (쿠폰 수정)") + @Nested + class UpdateCoupon { + + @DisplayName("유효한 정보로 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidRequest() { + Long couponId = createCoupon("기존 쿠폰", "FIXED", 5000L); + var request = new CouponV1Dto.UpdateCouponRequest("수정된 쿠폰", 3000L, 20000L, FUTURE.plusDays(10), null); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId, HttpMethod.PUT, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 수정 확인 + ResponseEntity> getResponse = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertAll( + () -> assertThat(getResponse.getBody().data().coupon().name()).isEqualTo("수정된 쿠폰"), + () -> assertThat(getResponse.getBody().data().coupon().value()).isEqualTo(3000L), + () -> assertThat(getResponse.getBody().data().coupon().minOrderAmount()).isEqualTo(20000L) + ); + } + + @DisplayName("존재하지 않는 쿠폰을 수정하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenCouponNotExists() { + var request = new CouponV1Dto.UpdateCouponRequest("이름", 3000L, null, FUTURE, null); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/999", HttpMethod.PUT, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api-admin/v1/coupons/{couponId} (쿠폰 삭제)") + @Nested + class DeleteCoupon { + + @DisplayName("존재하는 쿠폰을 삭제하면, 200 OK 응답을 받고 조회 시 404가 반환된다.") + @Test + void returnsOk_whenCouponExists() { + Long couponId = createCoupon("쿠폰", "FIXED", 5000L); + + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId, HttpMethod.DELETE, adminEntity(null), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 삭제 후 조회 시 NOT_FOUND + ResponseEntity> getResponse = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api-admin/v1/coupons/{couponId}/issues (발급 내역 조회)") + @Nested + class GetIssuedCoupons { + + @DisplayName("발급 내역이 있으면, 목록을 반환한다.") + @Test + void returnsIssuedCoupons_whenIssuesExist() { + // Arrange + Long couponId = createCoupon("쿠폰", "FIXED", 5000L); + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + issueCoupon("user1", "Test1234!", couponId); + issueCoupon("user2", "Test1234!", couponId); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN + "/" + couponId + "/issues", HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().memberCoupons()).hasSize(2) + ); + } + } + + // --- Helper Methods --- + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } + + private Long createCoupon(String name, String type, Long value) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, null, FUTURE, null, null); + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private void issueCoupon(String loginId, String password, Long couponId) { + testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponConcurrencyE2ETest.java new file mode 100644 index 000000000..1df0176ef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponConcurrencyE2ETest.java @@ -0,0 +1,352 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.address.AddressV1Dto; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.coupon.CouponV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CouponConcurrencyE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public CouponConcurrencyE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("쿠폰 사용 동시성 테스트") + @Nested + class CouponUsageConcurrency { + + @DisplayName("같은 쿠폰을 동시에 2개 기기에서 사용해 주문하면, 1명만 성공한다.") + @Test + void concurrentCouponUsage_onlyOneSucceeds() throws InterruptedException { + // Arrange + int threadCount = 2; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 100000L, 100, 5); + + registerMember("user1", "Test1234!"); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L, null, LocalDateTime.now().plusDays(30)); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act — 같은 사용자가 같은 쿠폰으로 동시에 2건 주문 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — 쿠폰은 1회만 사용 가능 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + } + } + + @DisplayName("쿠폰 발급 동시성 테스트") + @Nested + class CouponIssueConcurrency { + + @DisplayName("수량 5개 쿠폰에 10명이 동시 발급 요청하면, 5명만 성공하고 issuedQuantity는 5가 된다.") + @Test + void concurrentCouponIssue_onlyLimitedSucceeds() throws InterruptedException { + // Arrange + int threadCount = 10; + Long couponId = createCouponWithQuantity("한정 쿠폰", "FIXED", 3000L, null, + LocalDateTime.now().plusDays(30), 5); + + String[] loginIds = new String[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "issueUser" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act — 10명이 동시에 쿠폰 발급 요청 + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.CREATED) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — 발급 수량 확인 + assertThat(successCount.get()).isEqualTo(5); + assertThat(failCount.get()).isEqualTo(5); + } + } + + @DisplayName("쿠폰 + 재고 부족 복원 테스트") + @Nested + class CouponWithStockFailConcurrency { + + @DisplayName("쿠폰 적용 주문이 재고 부족으로 실패하면, 쿠폰이 사용되지 않은 상태로 남는다.") + @Test + void couponOrder_withInsufficientStock_couponRemainAvailable() throws InterruptedException { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 100000L, 1, 5); + + registerMember("couponStockUser1", "Test1234!"); + registerMember("couponStockUser2", "Test1234!"); + Long addressId1 = registerAddress("couponStockUser1", "Test1234!"); + Long addressId2 = registerAddress("couponStockUser2", "Test1234!"); + + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L, null, LocalDateTime.now().plusDays(30)); + Long memberCouponId1 = issueCouponAndGetId("couponStockUser1", "Test1234!", couponId); + Long memberCouponId2 = issueCouponAndGetId("couponStockUser2", "Test1234!", couponId); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act — 재고 1개인 상품에 2명이 쿠폰 적용하여 동시 주문 + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId1, memberCouponId1, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("couponStockUser1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId2, memberCouponId2, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("couponStockUser2", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + + latch.await(); + executor.shutdown(); + + // Assert — 1명 성공, 1명 실패 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + + // 재고 0 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(0); + + // 실패한 사용자의 쿠폰은 AVAILABLE 상태 확인 + long usedCount = countCouponStatus(memberCouponId1, "couponStockUser1", "Test1234!", "USED") + + countCouponStatus(memberCouponId2, "couponStockUser2", "Test1234!", "USED"); + long availableCount = countCouponStatus(memberCouponId1, "couponStockUser1", "Test1234!", "AVAILABLE") + + countCouponStatus(memberCouponId2, "couponStockUser2", "Test1234!", "AVAILABLE"); + + assertThat(usedCount).isEqualTo(1); + assertThat(availableCount).isEqualTo(1); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/brands", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } + + private Long registerAddress(String loginId, String password) { + var request = new AddressV1Dto.CreateAddressRequest( + "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", "101호" + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/addresses", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().address().id(); + } + + private Long createCoupon(String name, String type, Long value, Long minOrderAmount, LocalDateTime expiredAt) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, minOrderAmount, expiredAt, null, null); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/coupons", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private Long createCouponWithQuantity(String name, String type, Long value, Long minOrderAmount, + LocalDateTime expiredAt, int totalQuantity) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, minOrderAmount, expiredAt, null, totalQuantity); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/coupons", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private Long issueCouponAndGetId(String loginId, String password, Long couponId) { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().memberCoupon().id(); + } + + private int countCouponStatus(Long memberCouponId, String loginId, String password, String expectedStatus) { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons?page=0&size=10", HttpMethod.GET, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return (int) response.getBody().data().memberCoupons().stream() + .filter(mc -> mc.id().equals(memberCouponId) && mc.status().equals(expectedStatus)) + .count(); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponV1ApiE2ETest.java new file mode 100644 index 000000000..72d51ff69 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CouponV1ApiE2ETest.java @@ -0,0 +1,330 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.coupon.CouponV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CouponV1ApiE2ETest { + + private static final String COUPON_ADMIN = "/api-admin/v1/coupons"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + private static final LocalDateTime PAST = LocalDateTime.now().minusDays(1); + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public CouponV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/coupons/{couponId}/issue (쿠폰 발급)") + @Nested + class IssueCoupon { + + @DisplayName("유효한 쿠폰을 발급하면, 201 Created 응답을 받는다.") + @Test + void returnsCreated_whenCouponIsValid() { + // Arrange + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L, FUTURE); + registerMember("user1", "Test1234!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().memberCoupon().couponId()).isEqualTo(couponId), + () -> assertThat(response.getBody().data().memberCoupon().status()).isEqualTo("AVAILABLE") + ); + } + + @DisplayName("존재하지 않는 쿠폰을 발급하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenCouponNotExists() { + registerMember("user1", "Test1234!"); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/999/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("만료된 쿠폰을 발급하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenCouponIsExpired() { + Long couponId = createCoupon("만료 쿠폰", "FIXED", 5000L, PAST); + registerMember("user1", "Test1234!"); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("수량이 소진된 쿠폰을 발급하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenQuantityExhausted() { + // Arrange — 수량 1개 쿠폰 생성 + Long couponId = createLimitedCoupon("선착순 쿠폰", "FIXED", 5000L, FUTURE, 1); + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + + // user1 발급 성공 + issueCoupon("user1", "Test1234!", couponId); + + // Act — user2 발급 시도 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user2", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("validDays 쿠폰을 발급하면, 개인 만료일이 설정된다.") + @Test + void returnsCreated_withPersonalExpiredAt_whenValidDaysCoupon() { + // Arrange — validDays=7인 기간제 쿠폰 + Long couponId = createValidDaysCoupon("기간제 쿠폰", "FIXED", 5000L, FUTURE, 7); + registerMember("user1", "Test1234!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert — 개인 만료일이 쿠폰 만료일(30일 후)보다 이전(7일 후 근처) + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().memberCoupon().expiredAt()).isBefore(FUTURE), + () -> assertThat(response.getBody().data().memberCoupon().expiredAt()) + .isAfter(LocalDateTime.now().plusDays(6)) + ); + } + + @DisplayName("이미 발급받은 쿠폰을 다시 발급하면, 409 Conflict 응답을 받는다.") + @Test + void returnsConflict_whenAlreadyIssued() { + Long couponId = createCoupon("쿠폰", "FIXED", 5000L, FUTURE); + registerMember("user1", "Test1234!"); + + // 첫 번째 발급 + testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference>() {} + ); + + // 두 번째 발급 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/users/me/coupons (내 쿠폰 목록 조회)") + @Nested + class GetMyCoupons { + + @DisplayName("발급받은 쿠폰이 있으면, 목록을 반환한다.") + @Test + void returnsMyCoupons_whenCouponsExist() { + // Arrange + Long couponId1 = createCoupon("쿠폰1", "FIXED", 5000L, FUTURE); + Long couponId2 = createCoupon("쿠폰2", "RATE", 10L, FUTURE); + registerMember("user1", "Test1234!"); + issueCoupon("user1", "Test1234!", couponId1); + issueCoupon("user1", "Test1234!", couponId2); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons", HttpMethod.GET, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().memberCoupons()).hasSize(2) + ); + } + + @DisplayName("발급받은 쿠폰이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoCoupons() { + registerMember("user1", "Test1234!"); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons", HttpMethod.GET, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().memberCoupons()).isEmpty() + ); + } + + @DisplayName("여러 종류의 쿠폰을 발급받아도 한 번의 조회로 모두 반환된다.") + @Test + void returnsMyCoupons_whenManyCouponsIssued() { + // Arrange — 5개 쿠폰 생성 후 모두 발급 + registerMember("user1", "Test1234!"); + for (int i = 1; i <= 5; i++) { + Long couponId = createCoupon("쿠폰" + i, "FIXED", 1000L * i, FUTURE); + issueCoupon("user1", "Test1234!", couponId); + } + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons", HttpMethod.GET, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().memberCoupons()).hasSize(5), + () -> assertThat(response.getBody().data().memberCoupons()) + .allSatisfy(mc -> assertThat(mc.coupon().name()).startsWith("쿠폰")) + ); + } + + @DisplayName("다른 사용자의 쿠폰은 조회되지 않는다.") + @Test + void returnsOnlyMyCoupons_notOtherUsers() { + Long couponId = createCoupon("쿠폰", "FIXED", 5000L, FUTURE); + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + issueCoupon("user1", "Test1234!", couponId); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons", HttpMethod.GET, + new HttpEntity<>(authHeaders("user2", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().memberCoupons()).isEmpty() + ); + } + } + + // --- Helper Methods --- + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } + + private Long createLimitedCoupon(String name, String type, Long value, LocalDateTime expiredAt, Integer totalQuantity) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, null, expiredAt, null, totalQuantity); + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private Long createValidDaysCoupon(String name, String type, Long value, LocalDateTime expiredAt, Integer validDays) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, null, expiredAt, validDays, null); + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private Long createCoupon(String name, String type, Long value, LocalDateTime expiredAt) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, null, expiredAt, null, null); + ResponseEntity> response = testRestTemplate.exchange( + COUPON_ADMIN, HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private void issueCoupon(String loginId, String password, Long couponId) { + testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java new file mode 100644 index 000000000..4310fefcc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeConcurrencyE2ETest.java @@ -0,0 +1,265 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.like.LikeV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeConcurrencyE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeConcurrencyE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품 좋아요 동시성 테스트") + @Nested + class ProductLikeConcurrency { + + @DisplayName("동시에 10명이 같은 상품에 좋아요를 누르면, likeCount가 정확히 10이 된다.") + @Test + void concurrentLikes_incrementCorrectly() throws InterruptedException { + // Arrange + int threadCount = 10; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + String[] loginIds = new String[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "user" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act — 10명이 동시에 좋아요 + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — likeCount 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(productResponse.getBody().data().product().likeCount()).isEqualTo(10); + } + } + + @DisplayName("좋아요 토글 동시성 테스트") + @Nested + class LikeToggleConcurrency { + + @DisplayName("같은 사용자가 동시에 좋아요를 2번 토글하면, 최종 likeCount가 0 또는 1이다.") + @Test + void concurrentLikeToggle_finalStateConsistent() throws InterruptedException { + // Arrange + int threadCount = 2; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + registerMember("toggleUser", "Test1234!"); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act — 같은 사용자가 동시에 2번 좋아요 토글 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders("toggleUser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — likeCount가 0 또는 1 (동시 토글이므로 둘 다 가능) + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + int likeCount = productResponse.getBody().data().product().likeCount(); + assertThat(likeCount).isIn(0, 1); + // 성공 + 실패 = threadCount (Unique 제약조건으로 하나가 실패할 수도 있음) + assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount); + } + } + + @DisplayName("브랜드 좋아요 동시성 테스트") + @Nested + class BrandLikeConcurrency { + + @DisplayName("동시에 10명이 같은 브랜드에 좋아요를 누르면, likeCount가 정확히 10이 된다.") + @Test + void concurrentBrandLikes_incrementCorrectly() throws InterruptedException { + // Arrange + int threadCount = 10; + Long brandId = registerBrand("Nike", "Just Do It"); + + String[] loginIds = new String[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "brandLikeUser" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act — 10명이 동시에 브랜드 좋아요 + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — likeCount 확인 + ResponseEntity> brandResponse = testRestTemplate.exchange( + "/api/v1/brands/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(brandResponse.getBody().data().brand().likeCount()).isEqualTo(10); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/brands", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java index 3d4db81a2..dcc849c19 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -142,6 +142,32 @@ void returnsBadRequest_whenInvalidEmail() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } + + @DisplayName("존재하지 않는 성별이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenInvalidGender() { + // arrange + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + "testUser3", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "INVALID_GENDER", + "test@example.com", + null + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } @DisplayName("GET /api/v1/members/me (내 정보 조회)") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderConcurrencyE2ETest.java new file mode 100644 index 000000000..34e115324 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderConcurrencyE2ETest.java @@ -0,0 +1,729 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.address.AddressV1Dto; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderConcurrencyE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderConcurrencyE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("재고 동시성 테스트") + @Nested + class StockConcurrency { + + @DisplayName("동시에 10명이 같은 상품을 1개씩 주문하면, 재고가 정확히 10개 차감된다.") + @Test + void concurrentOrders_decreaseStockCorrectly() throws InterruptedException { + // Arrange + int threadCount = 10; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + String[] loginIds = new String[threadCount]; + Long[] addressIds = new Long[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "user" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + addressIds[i] = registerAddress(loginId, "Test1234!"); + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act — 동시에 10개 주문 + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressIds[idx], null, items); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — 재고 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(90); + } + + @DisplayName("재고 5개인 상품에 10명이 동시 주문하면, 5명만 성공하고 재고는 0이 된다.") + @Test + void concurrentOrders_withInsufficientStock() throws InterruptedException { + // Arrange + int threadCount = 10; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 5, 5); + + String[] loginIds = new String[threadCount]; + Long[] addressIds = new Long[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "user" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + addressIds[i] = registerAddress(loginId, "Test1234!"); + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressIds[idx], null, items); + + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(successCount.get()).isEqualTo(5); + assertThat(failCount.get()).isEqualTo(5); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(0); + } + } + + @DisplayName("다중 상품 주문 데드락 방지 테스트") + @Nested + class MultiProductDeadlockPrevention { + + @DisplayName("두 스레드가 서로 다른 순서로 2개 상품을 주문해도 데드락 없이 완료되고 재고가 정확하다.") + @Test + void concurrentOrders_multipleProducts_deadlockPrevention() throws InterruptedException { + // Arrange + int threadCount = 2; + Long brandId = registerBrand("Nike", "Just Do It"); + Long p1 = registerProduct(brandId, "상품A", 10000L, 10, 5); + Long p2 = registerProduct(brandId, "상품B", 20000L, 10, 5); + + String[] loginIds = new String[threadCount]; + Long[] addressIds = new Long[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "deadlockUser" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + addressIds[i] = registerAddress(loginId, "Test1234!"); + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act — 스레드1: [p1, p2] 순서, 스레드2: [p2, p1] 순서로 주문 + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + var items = List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(p1, 1), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(p2, 1) + ); + var request = new OrderV1Dto.CreateOrderRequest(addressIds[0], null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginIds[0], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + done.countDown(); + } + }); + + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + var items = List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(p2, 1), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(p1, 1) + ); + var request = new OrderV1Dto.CreateOrderRequest(addressIds[1], null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginIds[1], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + done.countDown(); + } + }); + + ready.await(); + start.countDown(); + done.await(); + executor.shutdown(); + + // Assert — 두 주문 모두 성공, 재고 각각 1씩 차감 + assertThat(successCount.get()).isEqualTo(2); + + ResponseEntity> p1Response = testRestTemplate.exchange( + "/api/v1/products/" + p1, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + ResponseEntity> p2Response = testRestTemplate.exchange( + "/api/v1/products/" + p2, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(p1Response.getBody().data().product().stockQuantity()).isEqualTo(8); + assertThat(p2Response.getBody().data().product().stockQuantity()).isEqualTo(8); + } + + @DisplayName("세 스레드가 서로 다른 순서로 3개 상품을 주문해도 데드락 없이 완료된다.") + @Test + void concurrentOrders_threeProducts_deadlockPrevention() throws InterruptedException { + // Arrange + int threadCount = 3; + Long brandId = registerBrand("Adidas", "Impossible Is Nothing"); + Long p1 = registerProduct(brandId, "상품X", 10000L, 10, 5); + Long p2 = registerProduct(brandId, "상품Y", 20000L, 10, 5); + Long p3 = registerProduct(brandId, "상품Z", 30000L, 10, 5); + + String[] loginIds = new String[threadCount]; + Long[] addressIds = new Long[threadCount]; + for (int i = 0; i < threadCount; i++) { + String loginId = "triUser" + i; + registerMember(loginId, "Test1234!"); + loginIds[i] = loginId; + addressIds[i] = registerAddress(loginId, "Test1234!"); + } + + Long[][] productOrders = { + {p1, p2, p3}, + {p3, p1, p2}, + {p2, p3, p1} + }; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + final int idx = i; + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + var items = List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productOrders[idx][0], 1), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productOrders[idx][1], 1), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productOrders[idx][2], 1) + ); + var request = new OrderV1Dto.CreateOrderRequest(addressIds[idx], null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } + } finally { + done.countDown(); + } + }); + } + + ready.await(); + start.countDown(); + done.await(); + executor.shutdown(); + + // Assert — 3개 주문 모두 성공, 재고 각각 3씩 차감 + assertThat(successCount.get()).isEqualTo(3); + + ResponseEntity> p1Response = testRestTemplate.exchange( + "/api/v1/products/" + p1, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + ResponseEntity> p2Response = testRestTemplate.exchange( + "/api/v1/products/" + p2, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + ResponseEntity> p3Response = testRestTemplate.exchange( + "/api/v1/products/" + p3, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(p1Response.getBody().data().product().stockQuantity()).isEqualTo(7); + assertThat(p2Response.getBody().data().product().stockQuantity()).isEqualTo(7); + assertThat(p3Response.getBody().data().product().stockQuantity()).isEqualTo(7); + } + } + + @DisplayName("주문 취소 동시성 테스트") + @Nested + class OrderCancelConcurrency { + + @DisplayName("같은 주문을 동시에 2번 취소하면, 1번만 성공하고 재고는 정확히 1개만 복원된다.") + @Test + void concurrentOrderCancel_onlyOneSucceeds() throws InterruptedException { + // Arrange + int threadCount = 2; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + registerMember("cancelUser", "Test1234!"); + Long addressId = registerAddress("cancelUser", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); + ResponseEntity> orderResponse = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("cancelUser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + Long orderId = orderResponse.getBody().data().order().id(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // Act — 동시에 2번 취소 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, + new HttpEntity<>(authHeaders("cancelUser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + if (response.getStatusCode() == HttpStatus.OK) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — 1번만 취소 성공, 재고 정확히 1개 복원 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(1); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(100); + } + } + + @DisplayName("주문 생성 + 취소 경합 동시성 테스트") + @Nested + class OrderCreateAndCancelConcurrency { + + @DisplayName("주문 생성과 다른 주문 취소가 동시에 발생해도 재고 정합성이 유지된다.") + @Test + void concurrentCreateAndCancel_stockRemainsConsistent() throws InterruptedException { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 50, 5); + + // 먼저 5명이 순차적으로 주문 생성 (재고: 50 → 45) + List orderIds = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + String loginId = "preUser" + i; + registerMember(loginId, "Test1234!"); + Long addressId = registerAddress(loginId, "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginId, "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + orderIds.add(response.getBody().data().order().id()); + } + + // 5명의 새 주문자 + 5명의 취소자 준비 + int threadCount = 10; + String[] newLoginIds = new String[5]; + Long[] newAddressIds = new Long[5]; + for (int i = 0; i < 5; i++) { + String loginId = "newUser" + i; + registerMember(loginId, "Test1234!"); + newLoginIds[i] = loginId; + newAddressIds[i] = registerAddress(loginId, "Test1234!"); + } + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger createSuccess = new AtomicInteger(0); + AtomicInteger cancelSuccess = new AtomicInteger(0); + + // Act — 5명 주문 생성 + 5명 주문 취소 동시 실행 + for (int i = 0; i < 5; i++) { + final int idx = i; + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(newAddressIds[idx], null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(newLoginIds[idx], "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + createSuccess.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + + final Long orderId = orderIds.get(i); + final String cancelLoginId = "preUser" + i; + executor.submit(() -> { + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, + new HttpEntity<>(authHeaders(cancelLoginId, "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + cancelSuccess.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert — 재고 정합성: 초기 50 - 5(기존) + 5(취소 복원) - 5(신규) = 45 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(createSuccess.get()).isEqualTo(5); + assertThat(cancelSuccess.get()).isEqualTo(5); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(45); + } + } + + @DisplayName("배송지 수정 + 주문 취소 동시성 테스트") + @Nested + class ShippingAddressUpdateAndCancelConcurrency { + + @DisplayName("배송지 수정과 주문 취소가 동시에 발생하면, 취소된 주문의 배송지가 수정되지 않는다.") + @Test + void concurrentUpdateAndCancel_preventsUpdateOnCancelledOrder() throws InterruptedException { + // Arrange + int threadCount = 2; + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + registerMember("shippingUser", "Test1234!"); + Long addressId = registerAddress("shippingUser", "Test1234!"); + HttpHeaders headers = authHeaders("shippingUser", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var createRequest = new OrderV1Dto.CreateOrderRequest(addressId, null, items); + ResponseEntity> orderResponse = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(createRequest, headers), + new ParameterizedTypeReference<>() {} + ); + Long orderId = orderResponse.getBody().data().order().id(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger cancelSuccess = new AtomicInteger(0); + AtomicInteger updateSuccess = new AtomicInteger(0); + + // Act — 배송지 수정과 주문 취소를 동시에 실행 + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + cancelSuccess.incrementAndGet(); + } + } catch (Exception ignored) {} + }); + + executor.submit(() -> { + ready.countDown(); + try { start.await(); } catch (InterruptedException ignored) {} + try { + var updateRequest = new OrderV1Dto.UpdateShippingAddressRequest( + "김철수", "010-9999-9999", "54321", "서울시 서초구", "202호" + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/shipping-address", HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + updateSuccess.incrementAndGet(); + } + } catch (Exception ignored) {} + }); + + ready.await(); + start.countDown(); + executor.shutdown(); + executor.awaitTermination(10, TimeUnit.SECONDS); + + // Assert — 주문 상태 확인 + ResponseEntity> finalOrder = testRestTemplate.exchange( + "/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + String finalStatus = finalOrder.getBody().data().order().status(); + + assertThat(cancelSuccess.get()).isEqualTo(1); + assertThat(finalStatus).isEqualTo("CANCELLED"); + assertThat(updateSuccess.get()).isIn(0, 1); + } + } + + @DisplayName("재고 수정 + 주문 동시성 테스트") + @Nested + class StockUpdateAndOrderConcurrency { + + @DisplayName("관리자가 재고를 수정하는 동안 고객이 주문하면, 둘 다 정확히 반영된다.") + @Test + void concurrentStockUpdateAndOrder_bothAppliedCorrectly() throws InterruptedException { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + + registerMember("stockUpdateUser", "Test1234!"); + Long addressId = registerAddress("stockUpdateUser", "Test1234!"); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger orderSuccess = new AtomicInteger(0); + AtomicInteger stockUpdateSuccess = new AtomicInteger(0); + + // Act — 관리자 재고 수정(50)과 고객 주문(1개)을 동시에 실행 + executor.submit(() -> { + try { + var request = new ProductV1Dto.UpdateStockRequest(50); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products/" + productId + "/stock", HttpMethod.PATCH, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + stockUpdateSuccess.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + + executor.submit(() -> { + try { + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("stockUpdateUser", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + if (response.getStatusCode() == HttpStatus.OK) { + orderSuccess.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + + latch.await(); + executor.shutdown(); + + // Assert — 둘 다 성공하고, 최종 재고가 49 또는 50 (실행 순서에 따라 다름) + assertThat(orderSuccess.get() + stockUpdateSuccess.get()).isEqualTo(2); + + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + int finalStock = productResponse.getBody().data().product().stockQuantity(); + assertThat(finalStock).isIn(49, 50); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/brands", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } + + private Long registerAddress(String loginId, String password) { + var request = new AddressV1Dto.CreateAddressRequest( + "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", "101호" + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/addresses", HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().address().id(); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java index 6670df11b..989767cfc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -2,6 +2,7 @@ import com.loopers.interfaces.api.address.AddressV1Dto; import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.coupon.CouponV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; import com.loopers.interfaces.api.order.OrderV1Dto; import com.loopers.interfaces.api.product.ProductV1Dto; @@ -21,6 +22,7 @@ import org.springframework.http.ResponseEntity; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -60,7 +62,7 @@ void createsOrder_withSingleItem() { Long addressId = registerAddress("user1", "Test1234!"); var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2)); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -104,7 +106,7 @@ void createsOrder_withMultipleItems() { new OrderV1Dto.CreateOrderRequest.OrderItemRequest(product1, 1), new OrderV1Dto.CreateOrderRequest.OrderItemRequest(product2, 2) ); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -135,7 +137,7 @@ void mergesDuplicateItems() { new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2), new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 3) ); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -164,7 +166,7 @@ void returnsBadRequest_whenInsufficientStock() { Long addressId = registerAddress("user1", "Test1234!"); var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 4)); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -197,7 +199,7 @@ void returnsBadRequest_whenExceedsMaxOrderQuantity() { Long addressId = registerAddress("user1", "Test1234!"); var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 4)); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -220,7 +222,7 @@ void returnsNotFound_whenAddressNotExists() { Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); - var request = new OrderV1Dto.CreateOrderRequest(9999L, items); + var request = new OrderV1Dto.CreateOrderRequest(9999L, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -241,7 +243,7 @@ void returnsBadRequest_whenEmptyItems() { registerMember("user1", "Test1234!"); Long addressId = registerAddress("user1", "Test1234!"); - var request = new OrderV1Dto.CreateOrderRequest(addressId, List.of()); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, List.of()); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -265,7 +267,7 @@ void returnsBadRequest_whenQuantityZeroOrNegative() { Long addressId = registerAddress("user1", "Test1234!"); var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 0)); - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); // Act ResponseEntity> response = testRestTemplate.exchange( @@ -283,7 +285,7 @@ void returnsBadRequest_whenQuantityZeroOrNegative() { @Test void returnsUnauthorized_whenNoAuth() { // Act - var request = new OrderV1Dto.CreateOrderRequest(1L, List.of()); + var request = new OrderV1Dto.CreateOrderRequest(1L, null, List.of()); ResponseEntity> response = testRestTemplate.exchange( "/api/v1/orders", HttpMethod.POST, @@ -693,6 +695,240 @@ void returnsOrderDetail() { } } + @DisplayName("POST /api/v1/orders (쿠폰 적용 주문)") + @Nested + class CreateOrderWithCoupon { + + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(30); + + @DisplayName("정액 쿠폰 적용 시, 할인 금액만큼 차감된 totalAmount로 주문이 생성된다.") + @Test + void createsOrder_withFixedCoupon() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L, null, FUTURE); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(273000L), + () -> assertThat(response.getBody().data().order().originalAmount()).isEqualTo(278000L), + () -> assertThat(response.getBody().data().order().discountAmount()).isEqualTo(5000L), + () -> assertThat(response.getBody().data().order().memberCouponId()).isEqualTo(memberCouponId) + ); + } + + @DisplayName("정률 쿠폰 적용 시, 주문 금액 비율로 할인된다.") + @Test + void createsOrder_withRateCoupon() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 100000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("10% 할인", "RATE", 10L, null, FUTURE); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(90000L), + () -> assertThat(response.getBody().data().order().originalAmount()).isEqualTo(100000L), + () -> assertThat(response.getBody().data().order().discountAmount()).isEqualTo(10000L) + ); + } + + @DisplayName("존재하지 않는 memberCouponId로 주문하면, 404를 반환한다.") + @Test + void returnsNotFound_whenMemberCouponNotExists() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, 9999L, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + + // 재고 롤백 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(100); + } + + @DisplayName("이미 사용된 쿠폰으로 주문하면, 400을 반환하고 재고가 롤백된다.") + @Test + void returnsBadRequest_whenCouponAlreadyUsed() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("쿠폰", "FIXED", 5000L, null, FUTURE); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + + // 첫 번째 주문으로 쿠폰 사용 + var items1 = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request1 = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items1); + testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request1, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference>() {} + ); + + // Act — 같은 쿠폰으로 두 번째 주문 + var items2 = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request2 = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items2); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request2, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + // 재고 롤백 확인 (첫 주문으로 1개 차감 → 99개여야 함) + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(99); + } + + @DisplayName("최소 주문 금액 미달 시, 400을 반환한다.") + @Test + void returnsBadRequest_whenBelowMinOrderAmount() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 10000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("쿠폰", "FIXED", 5000L, 50000L, FUTURE); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("타인의 쿠폰으로 주문하면, 400을 반환한다.") + @Test + void returnsBadRequest_whenNotCouponOwner() { + // Arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId1 = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("쿠폰", "FIXED", 5000L, null, FUTURE); + Long memberCouponId = issueCouponAndGetId("user2", "Test1234!", couponId); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(addressId1, memberCouponId, items); + + // Act — user1이 user2의 쿠폰으로 주문 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("쿠폰 적용 주문 취소 시, 쿠폰이 복원되고 재고가 복원된다.") + @Test + void restoresCouponAndStock_whenCouponOrderCancelled() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + Long couponId = createCoupon("5000원 할인", "FIXED", 5000L, null, FUTURE); + Long memberCouponId = issueCouponAndGetId("user1", "Test1234!", couponId); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + // 쿠폰 적용 주문 생성 + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, memberCouponId, items); + ResponseEntity> orderResponse = testRestTemplate.exchange( + "/api/v1/orders", HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + Long orderId = orderResponse.getBody().data().order().id(); + + // Act — 주문 취소 + testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + // Assert — 재고 복원 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(100); + + // Assert — 쿠폰 복원 확인 (AVAILABLE 상태) + ResponseEntity> couponResponse = testRestTemplate.exchange( + "/api/v1/users/me/coupons", HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + assertThat(couponResponse.getBody().data().memberCoupons().get(0).status()).isEqualTo("AVAILABLE"); + } + } + // --- Helper Methods --- private void registerMember(String loginId, String password) { @@ -745,7 +981,7 @@ private Long registerAddress(String loginId, String password) { private void createOrder(HttpHeaders headers, Long addressId, List items) { - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); testRestTemplate.exchange( "/api/v1/orders", HttpMethod.POST, @@ -756,7 +992,7 @@ private void createOrder(HttpHeaders headers, Long addressId, private Long createOrderAndGetId(HttpHeaders headers, Long addressId, List items) { - var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + var request = new OrderV1Dto.CreateOrderRequest(addressId, null, items); ResponseEntity> response = testRestTemplate.exchange( "/api/v1/orders", HttpMethod.POST, @@ -793,4 +1029,22 @@ private HttpHeaders adminHeaders() { private HttpEntity adminEntity(T body) { return new HttpEntity<>(body, adminHeaders()); } + + private Long createCoupon(String name, String type, Long value, Long minOrderAmount, LocalDateTime expiredAt) { + var request = new CouponV1Dto.CreateCouponRequest(name, type, value, minOrderAmount, expiredAt, null, null); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/coupons", HttpMethod.POST, adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().coupon().id(); + } + + private Long issueCouponAndGetId(String loginId, String password, Long couponId) { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/coupons/" + couponId + "/issue", HttpMethod.POST, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().memberCoupon().id(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index 49bd65f12..dddd700e0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -278,6 +278,21 @@ void returnsSortedProducts_whenSortByPriceAsc() { () -> assertThat(response.getBody().data().products().get(2).price()).isEqualTo(159000L) ); } + + @DisplayName("존재하지 않는 정렬 타입이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenInvalidSortType() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "?sort=INVALID_SORT", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } @DisplayName("GET /api/v1/products/{productId} (상품 상세 조회)")