diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 9c550ab38..9fe5b4a2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -15,8 +16,8 @@ public class BrandAdminFacade { private final ProductApplicationService productApplicationService; private final LikeApplicationService likeApplicationService; - public void delete(Long brandId) { - List productIds = productApplicationService.findActiveProductIdsByBrandId(brandId); + public void delete(UUID brandId) { + List productIds = productApplicationService.findActiveProductIdsByBrandId(brandId); likeApplicationService.deleteByProductIds(productIds); productApplicationService.deleteSoftByBrandId(brandId); brandApplicationService.delete(brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java index f7de34090..79841054e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.Map; import java.util.stream.Collectors; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -42,7 +43,7 @@ public Brand create(CreateBrandCommand command) { } @Transactional(readOnly = true) - public Brand findById(Long id) { + public Brand findById(UUID id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); } @@ -53,7 +54,7 @@ public Page list(Pageable pageable) { } @Transactional(readOnly = true) - public Map findNamesByIds(Collection brandIds) { + public Map findNamesByIds(Collection brandIds) { return brandIds.stream() .distinct() .collect(Collectors.toMap( @@ -65,7 +66,7 @@ public Map findNamesByIds(Collection brandIds) { } @Transactional - public Brand update(Long id, UpdateBrandCommand command) { + public Brand update(UUID id, UpdateBrandCommand command) { Brand brand = brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); @@ -74,7 +75,7 @@ public Brand update(Long id, UpdateBrandCommand command) { } @Transactional - public void delete(Long id) { + public void delete(UUID id) { Brand brand = brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); brandRepository.delete(brand); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminApplicationService.java new file mode 100644 index 000000000..d689696f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponAdminApplicationService.java @@ -0,0 +1,76 @@ +package com.loopers.application.coupon; + +import com.loopers.application.coupon.command.CreateCouponCommand; +import com.loopers.application.coupon.command.UpdateCouponCommand; +import com.loopers.application.coupon.view.CouponIssueView; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CouponAdminApplicationService { + + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + + @Transactional(readOnly = true) + public Page list(Pageable pageable) { + return couponRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Coupon findById(UUID couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다.")); + } + + @Transactional + public Coupon create(CreateCouponCommand command) { + Coupon coupon = new Coupon( + command.name(), + command.type(), + command.value(), + command.minOrderAmount(), + command.expiredAt() + ); + return couponRepository.save(coupon); + } + + @Transactional + public Coupon update(UUID couponId, UpdateCouponCommand command) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다.")); + Coupon updated = coupon.update( + command.name(), + command.type(), + command.value(), + command.minOrderAmount(), + command.expiredAt() + ); + return couponRepository.save(updated); + } + + @Transactional + public void delete(UUID couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다.")); + couponRepository.delete(coupon); + } + + @Transactional(readOnly = true) + public Page listIssues(UUID couponId, Pageable pageable) { + couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "쿠폰을 찾을 수 없습니다.")); + return issuedCouponRepository.findByCouponId(couponId, pageable).map(CouponIssueView::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplicationService.java new file mode 100644 index 000000000..5dace03a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplicationService.java @@ -0,0 +1,114 @@ +package com.loopers.application.coupon; + +import com.loopers.application.coupon.command.UseCouponCommand; +import com.loopers.application.coupon.view.MyCouponView; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CouponApplicationService { + + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + + @Transactional + public void issue(UUID couponId, String memberId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + + if (!coupon.isUsableAt(LocalDateTime.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급할 수 없습니다."); + } + + IssuedCoupon issuedCoupon = new IssuedCoupon( + memberId, + couponId, + CouponStatus.AVAILABLE, + LocalDateTime.now(), + coupon.expiredAt(), + null + ); + + try { + issuedCouponRepository.save(issuedCoupon); + } catch (DataIntegrityViolationException e) { + throw new CoreException(ErrorType.CONFLICT, "이미 발급된 쿠폰입니다."); + } + } + + @Transactional(readOnly = true) + public List listMyCoupons(String memberId) { + List issuedCoupons = issuedCouponRepository.findByMemberId(memberId); + if (issuedCoupons.isEmpty()) { + return List.of(); + } + + List couponIds = issuedCoupons.stream().map(IssuedCoupon::couponId).distinct().toList(); + Map couponsById = couponRepository.findAllMapByIdIn(couponIds); + LocalDateTime now = LocalDateTime.now(); + + return issuedCoupons.stream() + .map(issued -> { + Coupon coupon = couponsById.get(issued.couponId()); + if (coupon == null) { + throw new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."); + } + return new MyCouponView( + issued.couponId(), + coupon.name(), + coupon.type(), + coupon.value(), + coupon.minOrderAmount(), + issued.expiredAt(), + issued.resolveStatusAt(now) + ); + }) + .toList(); + } + + @Transactional + public void use(UseCouponCommand command) { + IssuedCoupon issuedCoupon = issuedCouponRepository.findByMemberIdAndCouponId(command.memberId(), command.couponId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + + Coupon coupon = couponRepository.findById(command.couponId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); + + issuedCoupon.validateOwner(command.memberId()); + if (command.orderAmount() < coupon.minOrderAmount()) { + throw new CoreException(ErrorType.BAD_REQUEST, "최소 주문 금액 미달로 쿠폰을 사용할 수 없습니다."); + } + + int updatedCount = issuedCouponRepository.markUsedAtomically(command.memberId(), command.couponId(), LocalDateTime.now()); + if (updatedCount == 0) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용되었거나 만료된 쿠폰입니다."); + } + } + + @Transactional + public void cancelUse(UUID couponId, String memberId) { + IssuedCoupon issuedCoupon = issuedCouponRepository.findByMemberIdAndCouponId(memberId, couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급된 쿠폰을 찾을 수 없습니다.")); + + issuedCoupon.validateOwner(memberId); + int updatedCount = issuedCouponRepository.markAvailableAtomically(memberId, couponId, LocalDateTime.now()); + if (updatedCount == 0) { + throw new CoreException(ErrorType.CONFLICT, "사용 취소할 수 없는 쿠폰입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryApplicationService.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java rename to apps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryApplicationService.java index 0efbbc692..e6cd9d545 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/category/CategoryApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryApplicationService.java @@ -1,4 +1,4 @@ -package com.loopers.application.category; +package com.loopers.application.coupon.category; import com.loopers.domain.category.Category; import com.loopers.domain.category.CategoryRepository; @@ -8,13 +8,14 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.util.UUID; @Service @RequiredArgsConstructor public class CategoryApplicationService { private final CategoryRepository categoryRepository; - public Category findById(Long categoryId) { + public Category findById(UUID categoryId) { return categoryRepository.findById(categoryId) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "카테고리를 찾을 수 없습니다.")); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/CreateCouponCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/CreateCouponCommand.java new file mode 100644 index 000000000..19966d50c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/CreateCouponCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.coupon.command; + +import com.loopers.domain.coupon.CouponType; + +import java.time.LocalDateTime; + +public record CreateCouponCommand( + String name, + CouponType type, + int value, + int minOrderAmount, + LocalDateTime expiredAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UpdateCouponCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UpdateCouponCommand.java new file mode 100644 index 000000000..0b65df050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UpdateCouponCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.coupon.command; + +import com.loopers.domain.coupon.CouponType; + +import java.time.LocalDateTime; + +public record UpdateCouponCommand( + String name, + CouponType type, + int value, + int minOrderAmount, + LocalDateTime expiredAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UseCouponCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UseCouponCommand.java new file mode 100644 index 000000000..c2742d1c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/command/UseCouponCommand.java @@ -0,0 +1,24 @@ +package com.loopers.application.coupon.command; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.UUID; + +public record UseCouponCommand( + UUID couponId, + String memberId, + int orderAmount +) { + public UseCouponCommand { + if (couponId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 ID는 필수입니다."); + } + if (memberId == null || memberId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 식별자는 필수입니다."); + } + if (orderAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/CouponIssueView.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/CouponIssueView.java new file mode 100644 index 000000000..893104e29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/CouponIssueView.java @@ -0,0 +1,27 @@ +package com.loopers.application.coupon.view; + +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.IssuedCoupon; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record CouponIssueView( + UUID couponId, + String memberId, + CouponStatus status, + LocalDateTime issuedAt, + LocalDateTime expiredAt, + LocalDateTime usedAt +) { + public static CouponIssueView from(IssuedCoupon issuedCoupon) { + return new CouponIssueView( + issuedCoupon.couponId(), + issuedCoupon.memberId(), + issuedCoupon.status(), + issuedCoupon.issuedAt(), + issuedCoupon.expiredAt(), + issuedCoupon.usedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/MyCouponView.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/MyCouponView.java new file mode 100644 index 000000000..5877c4b28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/view/MyCouponView.java @@ -0,0 +1,18 @@ +package com.loopers.application.coupon.view; + +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record MyCouponView( + UUID couponId, + String name, + CouponType type, + int value, + int minOrderAmount, + LocalDateTime expiredAt, + CouponStatus status +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java index 9e4020f06..1d90cc4c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.UUID; @Service public class LikeApplicationService { @@ -22,7 +23,7 @@ public LikeApplicationService(LikeRepository likeRepository) { } @Transactional - public void register(String memberId, Long productId) { + public void register(String memberId, UUID productId) { if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); } @@ -35,7 +36,7 @@ public void register(String memberId, Long productId) { } @Transactional - public void cancel(String memberId, Long productId) { + public void cancel(String memberId, UUID productId) { if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { throw new CoreException(ErrorType.NOT_FOUND, "좋아요한 상품이 아닙니다."); } @@ -43,20 +44,20 @@ public void cancel(String memberId, Long productId) { } @Transactional(readOnly = true) - public void assertLiked(String memberId, Long productId) { + public void assertLiked(String memberId, UUID productId) { if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) { throw new CoreException(ErrorType.NOT_FOUND, "좋아요한 상품이 아닙니다."); } } @Transactional(readOnly = true) - public Page getMyLikeProductIds(String memberId, Pageable pageable) { + public Page getMyLikeProductIds(String memberId, Pageable pageable) { return likeRepository.findByMemberId(memberId, pageable) .map(Like::productId); } @Transactional - public void deleteByProductIds(List productIds) { + public void deleteByProductIds(List productIds) { if (productIds.isEmpty()) { return; } 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 c7691ecba..6d53d7710 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 @@ -7,6 +7,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -15,14 +16,14 @@ public class LikeFacade { private final LikeApplicationService likeApplicationService; private final ProductLikeAplicationService productLikeAplicationService; - public void register(Long productId, Member member) { + public void register(UUID productId, Member member) { String memberId = member.id().value(); productLikeAplicationService.validateLikeable(productId); likeApplicationService.register(memberId, productId); productLikeAplicationService.increaseLikeCount(productId); } - public void cancel(Long productId, Member member) { + public void cancel(UUID productId, Member member) { String memberId = member.id().value(); likeApplicationService.assertLiked(memberId, productId); productLikeAplicationService.validateCancelable(productId); @@ -31,7 +32,7 @@ public void cancel(Long productId, Member member) { } public Page getMyLikes(String memberId, Pageable pageable) { - Page likedProductIds = likeApplicationService.getMyLikeProductIds(memberId, pageable); + Page likedProductIds = likeApplicationService.getMyLikeProductIds(memberId, pageable); return productLikeAplicationService.getMyLikedProducts(likedProductIds, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java index 76702d529..1c7f71299 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; @RequiredArgsConstructor @Service @@ -30,12 +31,12 @@ public Member authenticate(AuthenticateCommand command) { return member; } - public Long findDbIdByMemberId(MemberId memberId) { + public UUID findDbIdByMemberId(MemberId memberId) { return memberRepository.findDbIdByMemberId(memberId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "회원을 찾을 수 없습니다.")); } - public Long findDbIdByMember(Member member) { + public UUID findDbIdByMember(Member member) { return memberRepository.findDbIdByMemberId(member.id()) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "회원을 찾을 수 없습니다.")); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java index 9368e2813..e6c7455ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -27,13 +27,13 @@ public class OrderApplicationService { private final OrderRepository orderRepository; @Transactional - public Order create(Long userId, List items) { + public Order create(String memberId, List items, UUID couponId) { if (items == null || items.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); } String orderNumber = UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(Locale.ROOT); - Order order = new Order(userId, orderNumber, items); + Order order = new Order(memberId, orderNumber, items, couponId); return orderRepository.save(order); } @@ -42,7 +42,7 @@ public Order cancel(OrderAccessRequest request) { Order order = orderRepository.findById(request.orderId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); - if (!request.isAdmin() && !order.isOwner(request.userId())) { + if (!request.isAdmin() && !order.isOwner(request.memberId())) { throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 취소할 수 없습니다."); } @@ -55,7 +55,7 @@ public Order getById(OrderAccessRequest request) { Order order = orderRepository.findById(request.orderId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); - if (!request.isAdmin() && !order.isOwner(request.userId())) { + if (!request.isAdmin() && !order.isOwner(request.memberId())) { throw new CoreException(ErrorType.FORBIDDEN, "타인의 주문을 조회할 수 없습니다."); } @@ -67,7 +67,7 @@ public Page listByUser(OrderListByUserRequest request) { ZoneId kst = ZoneId.of("Asia/Seoul"); ZonedDateTime startDateTime = request.startAt().atStartOfDay(kst); ZonedDateTime endDateTime = request.endAt().atTime(23, 59, 59).atZone(kst); - return orderRepository.findByUserId(request.userId(), startDateTime, endDateTime, request.pageable()); + return orderRepository.findByMemberId(request.memberId(), startDateTime, endDateTime, request.pageable()); } @Transactional(readOnly = true) 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 deleted file mode 100644 index c6e94e7f4..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.application.brand.BrandApplicationService; -import com.loopers.application.order.command.CreateOrderCommand; -import com.loopers.application.order.query.OrderAccessRequest; -import com.loopers.application.product.ProductStockApplicationService; -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Component -@RequiredArgsConstructor -public class OrderFacade { - - private final OrderApplicationService orderApplicationService; - private final ProductStockApplicationService productStockApplicationService; - private final BrandApplicationService brandApplicationService; - - public Order create(CreateOrderCommand command) { - if (command.items() == null || command.items().isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); - } - - List reservedProducts = - productStockApplicationService.reserveForOrder(command.items()); - - Map brandNames = reservedProducts.stream() - .map(ProductStockApplicationService.ReservedProduct::brandId) - .distinct() - .collect(Collectors.toMap( - brandId -> brandId, - brandId -> brandApplicationService.findById(brandId).name().value() - )); - - List orderItems = reservedProducts.stream() - .map(reserved -> new OrderItem( - reserved.productId(), - reserved.quantity(), - reserved.productName(), - reserved.productPrice(), - brandNames.get(reserved.brandId()) - )) - .toList(); - - return orderApplicationService.create(command.userId(), orderItems); - } - - public Order cancel(OrderAccessRequest request) { - Order cancelled = orderApplicationService.cancel(request); - productStockApplicationService.restoreForOrder(cancelled.items()); - return cancelled; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java index c2e748bb1..2bd23baa8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryApplicationService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -12,7 +13,7 @@ public class OrderQueryApplicationService { private final OrderRepository orderRepository; @Transactional(readOnly = true) - public boolean existsOrderItemByProductId(Long productId) { + public boolean existsOrderItemByProductId(UUID productId) { return orderRepository.existsOrderItemByProductId(productId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderUseCase.java new file mode 100644 index 000000000..ec54ee51f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderUseCase.java @@ -0,0 +1,81 @@ +package com.loopers.application.order; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.coupon.CouponApplicationService; +import com.loopers.application.coupon.command.UseCouponCommand; +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.application.product.ProductStockApplicationService; +import com.loopers.application.product.dto.OrderProductInfo; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrderUseCase { + + private final OrderApplicationService orderApplicationService; + private final ProductStockApplicationService productStockApplicationService; + private final BrandApplicationService brandApplicationService; + private final CouponApplicationService couponApplicationService; + + @Transactional + public Order create(CreateOrderCommand command) { + if (command.items() == null || command.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + + List orderProducts = + productStockApplicationService.getOrderProducts(command.items()); + + List brandIds = orderProducts.stream() + .map(OrderProductInfo::brandId) + .toList(); + Map brandNames = brandApplicationService.findNamesByIds(brandIds); + + Map orderProductMap = orderProducts.stream() + .collect(Collectors.toMap(OrderProductInfo::productId, p -> p)); + + List orderItems = command.items().stream() + .map(item -> { + var p = orderProductMap.get(item.productId()); + return new OrderItem( + p.productId(), + item.quantity(), + p.productName(), + p.productPrice(), + brandNames.get(p.brandId()) + ); + }) + .toList(); + + if (command.couponId() != null) { + int orderAmount = orderItems.stream().mapToInt(OrderItem::totalPrice).sum(); + couponApplicationService.use(new UseCouponCommand(command.couponId(), command.memberId(), orderAmount)); + } + + Order createdOrder = orderApplicationService.create(command.memberId(), orderItems, command.couponId()); + productStockApplicationService.decreaseStockForOrder(command.items()); + return createdOrder; + } + + @Transactional + public Order cancel(OrderAccessRequest request) { + Order cancelled = orderApplicationService.cancel(request); + productStockApplicationService.restoreForOrder(cancelled.items()); + if (cancelled.couponId() != null) { + couponApplicationService.cancelUse(cancelled.couponId(), cancelled.memberId()); + } + return cancelled; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java index 04ff7acfa..c5ba6b273 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/command/CreateOrderCommand.java @@ -1,10 +1,16 @@ package com.loopers.application.order.command; import java.util.List; +import java.util.UUID; public record CreateOrderCommand( - Long userId, - List items + String memberId, + List items, + UUID couponId ) { - public record OrderItemCommand(Long productId, int quantity) {} + public CreateOrderCommand(String memberId, List items) { + this(memberId, items, null); + } + + public record OrderItemCommand(UUID productId, int quantity) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java index b3e391ee4..829274a57 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderAccessRequest.java @@ -1,8 +1,12 @@ package com.loopers.application.order.query; +import java.util.UUID; public record OrderAccessRequest( - Long orderId, - Long userId, + UUID orderId, + String memberId, boolean isAdmin ) { + public OrderAccessRequest(UUID orderId, boolean isAdmin) { + this(orderId, null, isAdmin); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java index 1ebb2cc23..df0f68a0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/query/OrderListByUserRequest.java @@ -3,9 +3,10 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDate; +import java.util.UUID; public record OrderListByUserRequest( - Long userId, + String memberId, LocalDate startAt, LocalDate endAt, Pageable pageable diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index 753e6c234..1cb79ee8d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -5,6 +5,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -13,7 +14,7 @@ public class ProductAdminFacade { private final ProductApplicationService productApplicationService; private final OrderQueryApplicationService orderQueryApplicationService; - public void delete(Long productId) { + public void delete(UUID productId) { if (orderQueryApplicationService.existsOrderItemByProductId(productId)) { throw new CoreException(ErrorType.CONFLICT, "주문 이력이 있는 상품은 삭제할 수 없습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java index eb545e9f1..b3101539f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.ZonedDateTime; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -46,13 +47,13 @@ public Product create(CreateProductCommand command) { } @Transactional(readOnly = true) - public Product get(Long productId) { + public Product get(UUID productId) { return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } @Transactional(readOnly = true) - public Page list(Long brandId, Pageable pageable) { + public Page list(UUID brandId, Pageable pageable) { return productRepository.findAll(brandId, pageable); } @@ -62,13 +63,13 @@ public Page list(ProductListCriteria criteria) { } @Transactional(readOnly = true) - public Product getIncludingDeleted(Long productId) { + public Product getIncludingDeleted(UUID productId) { return productRepository.findByIdIncludingDeleted(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } @Transactional(readOnly = true) - public Page listIncludingDeleted(Long brandId, Pageable pageable) { + public Page listIncludingDeleted(UUID brandId, Pageable pageable) { return productRepository.findAllIncludingDeleted(brandId, pageable); } @@ -78,17 +79,17 @@ public Page listIncludingDeleted(ProductListCriteria criteria) { } @Transactional(readOnly = true) - public java.util.List findActiveProductIdsByBrandId(Long brandId) { + public java.util.List findActiveProductIdsByBrandId(UUID brandId) { return productRepository.findIdsByBrandId(brandId); } @Transactional - public void deleteSoftByBrandId(Long brandId) { + public void deleteSoftByBrandId(UUID brandId) { productRepository.softDeleteByBrandId(brandId); } @Transactional - public Product update(Long productId, UpdateProductCommand command) { + public Product update(UUID productId, UpdateProductCommand command) { Product existing = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); @@ -115,7 +116,7 @@ public Product update(Long productId, UpdateProductCommand command) { } @Transactional - public void deleteSoft(Long productId) { + public void deleteSoft(UUID productId) { Product existing = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java index 467cf7f83..00e80dc30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java @@ -11,7 +11,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -20,7 +23,7 @@ public class ProductLikeAplicationService { private final ProductRepository productRepository; @Transactional(readOnly = true) - public void validateLikeable(Long productId) { + public void validateLikeable(UUID productId) { if (productRepository.findById(productId).isPresent()) { return; } @@ -31,30 +34,45 @@ public void validateLikeable(Long productId) { } @Transactional(readOnly = true) - public void validateCancelable(Long productId) { + public void validateCancelable(UUID productId) { productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } @Transactional - public void increaseLikeCount(Long productId) { - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); - productRepository.save(product.increaseLikeCount()); + public void increaseLikeCount(UUID productId) { + int updatedCount = productRepository.updateLikeCount(productId, 1); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } } @Transactional - public void decreaseLikeCount(Long productId) { - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); - productRepository.save(product.decreaseLikeCount()); + public void decreaseLikeCount(UUID productId) { + int updatedCount = productRepository.updateLikeCount(productId, -1); + if (updatedCount == 0) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + } + + @Transactional + public void decreaseLikeCountIfPresent(UUID productId) { + productRepository.updateLikeCount(productId, -1); } @Transactional(readOnly = true) - public Page getMyLikedProducts(Page likedProductIds, Pageable pageable) { - List products = likedProductIds.getContent().stream() - .map(productRepository::findById) - .flatMap(java.util.Optional::stream) + public Page getMyLikedProducts(Page likedProductIds, Pageable pageable) { + List ids = likedProductIds.getContent(); + if (ids.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + Map productsById = productRepository.findAllByIdIn(ids).stream() + .collect(LinkedHashMap::new, (map, product) -> map.put(product.id(), product), LinkedHashMap::putAll); + + List products = ids.stream() + .map(productsById::get) + .filter(java.util.Objects::nonNull) .toList(); return new PageImpl<>(products, pageable, products.size()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java index 8f38eed6a..d6b1e4163 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -22,22 +23,22 @@ public class ProductQueryFacade { private final BrandApplicationService brandApplicationService; private final ProductService productService; - public ProductView get(Long productId) { + public ProductView get(UUID productId) { Product product = productApplicationService.get(productId); - Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); return ProductView.from(product, brandNames.get(product.brandId())); } - public ProductView getIncludingDeleted(Long productId) { + public ProductView getIncludingDeleted(UUID productId) { Product product = productApplicationService.getIncludingDeleted(productId); - Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); return ProductView.from(product, brandNames.get(product.brandId())); } public ProductListView list(ProductListQuery query) { ProductListCriteria criteria = productService.toCriteria(query); Page products = productApplicationService.list(criteria); - Map brandNames = brandApplicationService.findNamesByIds( + Map brandNames = brandApplicationService.findNamesByIds( products.getContent().stream().map(Product::brandId).toList() ); List items = products.getContent().stream() @@ -55,7 +56,7 @@ public ProductListView list(ProductListQuery query) { public ProductListView listIncludingDeleted(ProductListQuery query) { ProductListCriteria criteria = productService.toCriteria(query); Page products = productApplicationService.listIncludingDeleted(criteria); - Map brandNames = brandApplicationService.findNamesByIds( + Map brandNames = brandApplicationService.findNamesByIds( products.getContent().stream().map(Product::brandId).toList() ); List items = products.getContent().stream() @@ -71,12 +72,12 @@ public ProductListView listIncludingDeleted(ProductListQuery query) { } public ProductView toView(Product product) { - Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); + Map brandNames = brandApplicationService.findNamesByIds(List.of(product.brandId())); return ProductView.from(product, brandNames.get(product.brandId())); } public ProductListView toListView(Page products) { - Map brandNames = brandApplicationService.findNamesByIds( + Map brandNames = brandApplicationService.findNamesByIds( products.getContent().stream().map(Product::brandId).toList() ); List items = products.getContent().stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java index 6d0344f0f..222f384ed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductStockApplicationService.java @@ -1,6 +1,8 @@ package com.loopers.application.product; import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.application.product.dto.OrderProductInfo; +import com.loopers.application.product.dto.ReservedProductResult; import com.loopers.domain.order.OrderItem; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -13,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -21,12 +24,12 @@ public class ProductStockApplicationService { private final ProductRepository productRepository; @Transactional - public List reserveForOrder(List items) { - List productIds = items.stream() + public List reserveForOrder(List items) { + List productIds = items.stream() .map(CreateOrderCommand.OrderItemCommand::productId) .toList(); - List products = productRepository.findAllByIdInWithLock(productIds); + List products = productRepository.findAllByIdIn(productIds); if (products.size() != productIds.size()) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); } @@ -37,15 +40,17 @@ public List reserveForOrder(List productMap = products.stream() + Map productMap = products.stream() .collect(Collectors.toMap(Product::id, product -> product)); return items.stream() .map(item -> { Product product = productMap.get(item.productId()); - Product updated = product.decreaseStock(item.quantity()); - productRepository.save(updated); - return new ReservedProduct( + int updated = productRepository.decreaseStockAtomically(product.id(), item.quantity()); + if (updated == 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new ReservedProductResult( product.id(), item.quantity(), product.name(), @@ -56,6 +61,59 @@ public List reserveForOrder(List getOrderProducts(List items) { + List productIds = items.stream() + .map(CreateOrderCommand.OrderItemCommand::productId) + .toList(); + + List products = productRepository.findAllByIdIn(productIds); + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품이 포함되어 있습니다."); + } + + for (Product product : products) { + if (product.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품이 포함되어 있습니다."); + } + } + + return products.stream() + .map(product -> new OrderProductInfo( + product.id(), + product.name(), + product.price(), + product.brandId() + )) + .toList(); + } + + @Transactional + public void decreaseStockForOrder(List items) { + for (CreateOrderCommand.OrderItemCommand item : items) { + decreaseStockWithAtomicUpdate(item.productId(), item.quantity()); + } + } + + @Transactional + public void decreaseStockWithAtomicUpdate(UUID productId, int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + + if (product.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "삭제된 상품입니다."); + } + + int updated = productRepository.decreaseStockAtomically(productId, quantity); + if (updated == 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + } + @Transactional public void restoreForOrder(List items) { for (OrderItem item : items) { @@ -66,12 +124,4 @@ public void restoreForOrder(List items) { } } - public record ReservedProduct( - Long productId, - int quantity, - String productName, - int productPrice, - Long brandId - ) { - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java index 9f51a8560..d0f0cbba9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/command/CreateProductCommand.java @@ -1,11 +1,12 @@ package com.loopers.application.product.command; +import java.util.UUID; public record CreateProductCommand( String name, Integer price, Integer stock, String description, - Long categoryId, - Long brandId + UUID categoryId, + UUID brandId ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java index 30026add4..c4927a9e9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/command/UpdateProductCommand.java @@ -1,11 +1,12 @@ package com.loopers.application.product.command; +import java.util.UUID; public record UpdateProductCommand( String name, Integer price, Integer stock, String description, - Long categoryId, - Long brandId + UUID categoryId, + UUID brandId ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/OrderProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/OrderProductInfo.java new file mode 100644 index 000000000..6772fc301 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/OrderProductInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application.product.dto; + +import java.util.UUID; + +public record OrderProductInfo( + UUID productId, + String productName, + int productPrice, + UUID brandId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ReservedProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ReservedProductResult.java new file mode 100644 index 000000000..105bed578 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ReservedProductResult.java @@ -0,0 +1,12 @@ +package com.loopers.application.product.dto; + +import java.util.UUID; + +public record ReservedProductResult( + UUID productId, + int quantity, + String productName, + int productPrice, + UUID brandId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java index 84447bf72..574763561 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/view/ProductView.java @@ -3,15 +3,16 @@ import com.loopers.domain.product.Product; import java.time.ZonedDateTime; +import java.util.UUID; public record ProductView( - Long id, + UUID id, String name, Integer price, Integer stock, String description, - Long categoryId, - Long brandId, + UUID categoryId, + UUID brandId, String brandName, Integer likeCount, ZonedDateTime deletedAt 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 2e8831ef1..31e08ec8b 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 @@ -1,9 +1,10 @@ package com.loopers.domain.brand; import com.loopers.domain.brand.vo.BrandName; +import java.util.UUID; public record Brand( - Long id, + UUID id, BrandName name, String description, String imageUrl 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 9e00dcdc7..90ca77025 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 @@ -5,15 +5,16 @@ import org.springframework.data.domain.Pageable; import java.util.Optional; +import java.util.UUID; public interface BrandRepository { Brand save(Brand brand); - Optional findById(Long id); + Optional findById(UUID id); Page findAll(Pageable pageable); - boolean existsById(Long id); + boolean existsById(UUID id); boolean existsByName(BrandName name); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java index 5f930fc17..99c5a96ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/Category.java @@ -2,9 +2,10 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.util.UUID; public record Category( - Long id, + UUID id, String name ) { public Category(String name) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java index 61df675d3..9457344fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/category/CategoryRepository.java @@ -4,11 +4,12 @@ import org.springframework.data.domain.Pageable; import java.util.Optional; +import java.util.UUID; public interface CategoryRepository { Category save(Category category); - Optional findById(Long id); + Optional findById(UUID id); Page findAll(Pageable pageable); } 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..87f5c5631 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,59 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record Coupon( + UUID id, + String name, + CouponType type, + int value, + int minOrderAmount, + LocalDateTime expiredAt +) { + public Coupon(String name, CouponType type, int value, int minOrderAmount, LocalDateTime expiredAt) { + this(null, name, type, value, minOrderAmount, expiredAt); + } + + public Coupon { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 필수입니다."); + } + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 값은 1 이상이어야 합니다."); + } + if (type == CouponType.RATE && value > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 쿠폰 값은 100 이하여야 합니다."); + } + if (minOrderAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "최소 주문 금액은 0 이상이어야 합니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료 시각은 필수입니다."); + } + } + + public int calculateDiscount(int orderAmount) { + if (orderAmount < minOrderAmount) { + return 0; + } + if (type == CouponType.FIXED) { + return Math.min(value, orderAmount); + } + return orderAmount * value / 100; + } + + public boolean isUsableAt(LocalDateTime now) { + return !now.isAfter(expiredAt); + } + + public Coupon update(String name, CouponType type, int value, int minOrderAmount, LocalDateTime expiredAt) { + return new Coupon(id, name, type, value, minOrderAmount, expiredAt); + } +} 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..f2621b0e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public interface CouponRepository { + Coupon save(Coupon coupon); + + Optional findById(UUID id); + + Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); + + Map findAllMapByIdIn(List ids); + + void delete(Coupon coupon); +} 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/IssuedCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java new file mode 100644 index 000000000..78e05cf66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,79 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record IssuedCoupon( + String memberId, + UUID couponId, + CouponStatus status, + LocalDateTime issuedAt, + LocalDateTime expiredAt, + LocalDateTime usedAt +) { + public IssuedCoupon { + if (memberId == null || memberId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 식별자는 필수입니다."); + } + if (couponId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 식별자는 필수입니다."); + } + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 상태는 필수입니다."); + } + if (issuedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "발급 시각은 필수입니다."); + } + if (expiredAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료 시각은 필수입니다."); + } + } + + public IssuedCoupon markUsed() { + if (status != CouponStatus.AVAILABLE) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 가능한 쿠폰만 사용할 수 있습니다."); + } + return new IssuedCoupon(memberId, couponId, CouponStatus.USED, issuedAt, expiredAt, LocalDateTime.now()); + } + + public IssuedCoupon markAvailable() { + return new IssuedCoupon(memberId, couponId, CouponStatus.AVAILABLE, issuedAt, expiredAt, null); + } + + public void validateOwner(String requesterMemberId) { + if (requesterMemberId == null || requesterMemberId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "요청자 식별자는 필수입니다."); + } + if (!memberId.equals(requesterMemberId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "요청자와 쿠폰 소유자가 일치하지 않습니다."); + } + } + + public void validateUsable(LocalDateTime now) { + if (now == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "기준 시각은 필수입니다."); + } + if (status != CouponStatus.AVAILABLE) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 가능한 쿠폰이 아닙니다."); + } + if (now.isAfter(expiredAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + } + + public CouponStatus resolveStatusAt(LocalDateTime now) { + if (now == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "기준 시각은 필수입니다."); + } + if (status == CouponStatus.USED) { + return CouponStatus.USED; + } + if (now.isAfter(expiredAt)) { + return CouponStatus.EXPIRED; + } + return CouponStatus.AVAILABLE; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java new file mode 100644 index 000000000..7e72b53cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IssuedCouponRepository { + IssuedCoupon save(IssuedCoupon issuedCoupon); + + Optional findByMemberIdAndCouponId(String memberId, UUID couponId); + + int markUsedAtomically(String memberId, UUID couponId, LocalDateTime now); + + int markAvailableAtomically(String memberId, UUID couponId, LocalDateTime now); + + Page findByCouponId(UUID couponId, Pageable pageable); + + List findByMemberId(String memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index cd2e10cdf..fdee5bf06 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -1,4 +1,5 @@ package com.loopers.domain.like; +import java.util.UUID; -public record Like(String memberId, Long productId) { +public record Like(String memberId, UUID productId) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index 2f74c318c..917ebc4ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -4,15 +4,16 @@ import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.UUID; public interface LikeRepository { Like save(Like like); - boolean existsByMemberIdAndProductId(String memberId, Long productId); + boolean existsByMemberIdAndProductId(String memberId, UUID productId); - void deleteByMemberIdAndProductId(String memberId, Long productId); + void deleteByMemberIdAndProductId(String memberId, UUID productId); - void deleteByProductIds(List productIds); + void deleteByProductIds(List productIds); Page findByMemberId(String memberId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java index 91c413427..db2d38ef2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -3,12 +3,13 @@ import com.loopers.domain.member.vo.MemberId; import java.util.Optional; +import java.util.UUID; public interface MemberRepository { Optional findByMemberId(MemberId memberId); - Optional findDbIdByMemberId(MemberId memberId); + Optional findDbIdByMemberId(MemberId memberId); boolean existsByMemberId(MemberId memberId); 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 71d534dac..ddddcbd0a 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 @@ -5,20 +5,22 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.UUID; public record Order( - Long id, - Long userId, + UUID id, + String memberId, String orderNumber, ZonedDateTime orderDate, OrderStatus status, int totalAmount, + UUID couponId, List items, ZonedDateTime deletedAt ) { public Order { - if (userId == null) { + if (memberId == null || memberId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } if (items == null || items.isEmpty()) { @@ -26,28 +28,48 @@ public record Order( } } - public Order(Long userId, String orderNumber, List items) { + public Order(String memberId, String orderNumber, List items) { this( null, - userId, + memberId, orderNumber, ZonedDateTime.now(), OrderStatus.ORDERED, items.stream().mapToInt(OrderItem::totalPrice).sum(), + null, + items, + null + ); + } + + public Order(String memberId, String orderNumber, List items, UUID couponId) { + this( + null, + memberId, + orderNumber, + ZonedDateTime.now(), + OrderStatus.ORDERED, + items.stream().mapToInt(OrderItem::totalPrice).sum(), + couponId, items, null ); } + public Order(UUID id, String memberId, String orderNumber, ZonedDateTime orderDate, OrderStatus status, + int totalAmount, List items, ZonedDateTime deletedAt) { + this(id, memberId, orderNumber, orderDate, status, totalAmount, null, items, deletedAt); + } + public Order cancel() { if (this.status == OrderStatus.CANCELLED) { throw new CoreException(ErrorType.CONFLICT, "이미 취소된 주문입니다."); } - return new Order(id, userId, orderNumber, orderDate, OrderStatus.CANCELLED, totalAmount, items, deletedAt); + return new Order(id, memberId, orderNumber, orderDate, OrderStatus.CANCELLED, totalAmount, couponId, items, deletedAt); } - public boolean isOwner(Long userId) { - return this.userId.equals(userId); + public boolean isOwner(String memberId) { + return this.memberId.equals(memberId); } public boolean isCancelled() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 93b3034d6..015a1b0fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -2,11 +2,12 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.util.UUID; public record OrderItem( - Long id, - Long orderId, - Long productId, + UUID id, + UUID orderId, + UUID productId, int quantity, String snapshotProductName, int snapshotPrice, @@ -28,7 +29,7 @@ public record OrderItem( } } - public OrderItem(Long productId, int quantity, String snapshotProductName, int snapshotPrice, String snapshotBrandName) { + public OrderItem(UUID productId, int quantity, String snapshotProductName, int snapshotPrice, String snapshotBrandName) { this(null, null, productId, quantity, snapshotProductName, snapshotPrice, snapshotBrandName); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 7d3116864..70014d54e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -5,14 +5,15 @@ import java.time.ZonedDateTime; import java.util.Optional; +import java.util.UUID; public interface OrderRepository { Order save(Order order); - Optional findById(Long id); + Optional findById(UUID id); - Page findByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable); + Page findByMemberId(String memberId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable); Page findAll(Pageable pageable); - boolean existsOrderItemByProductId(Long productId); + boolean existsOrderItemByProductId(UUID productId); } 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 9c9c3c62d..8ef76ff27 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 @@ -4,15 +4,16 @@ import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; +import java.util.UUID; public record Product( - Long id, + UUID id, String name, Integer price, Integer stock, String description, - Long categoryId, - Long brandId, + UUID categoryId, + UUID brandId, Integer likeCount, ZonedDateTime deletedAt ) { @@ -38,7 +39,7 @@ public record Product( } } - public Product(String name, Integer price, Integer stock, String description, Long categoryId, Long brandId) { + public Product(String name, Integer price, Integer stock, String description, UUID categoryId, UUID brandId) { this(null, name, price, stock, description, categoryId, brandId, 0, null); } 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 f57fdbab4..ac618d172 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 @@ -5,23 +5,28 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; public interface ProductRepository { Product save(Product product); - Optional findById(Long id); + Optional findById(UUID id); - List findAllByIdInWithLock(List ids); + List findAllByIdIn(List ids); - Optional findByIdIncludingDeleted(Long id); + int decreaseStockAtomically(UUID productId, int quantity); - Page findAll(Long brandId, Pageable pageable); + Optional findByIdIncludingDeleted(UUID id); - Page findAllIncludingDeleted(Long brandId, Pageable pageable); + Page findAll(UUID brandId, Pageable pageable); - List findIdsByBrandId(Long brandId); + Page findAllIncludingDeleted(UUID brandId, Pageable pageable); - void softDeleteByBrandId(Long brandId); + List findIdsByBrandId(UUID brandId); + + int updateLikeCount(UUID productId, long delta); + + void softDeleteByBrandId(UUID brandId); void delete(Product product); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java index 2d65d9da6..f504670a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java @@ -4,9 +4,10 @@ import com.loopers.support.error.ErrorType; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import java.util.UUID; public record ProductListCriteria( - Long brandId, + UUID brandId, int page, int size, ProductSortOption sortOption @@ -30,7 +31,7 @@ public record ProductListCriteria( } } - public static ProductListCriteria of(Long brandId, Integer page, Integer size, ProductSortOption sortOption) { + public static ProductListCriteria of(UUID brandId, Integer page, Integer size, ProductSortOption sortOption) { int resolvedPage = page == null ? DEFAULT_PAGE : page; int resolvedSize = size == null ? DEFAULT_SIZE : size; ProductSortOption resolvedSortOption = sortOption == null ? ProductSortOption.defaultOption() : sortOption; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java index 0859d5b28..d0a821ee5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java @@ -1,7 +1,8 @@ package com.loopers.domain.product.query; +import java.util.UUID; public record ProductListQuery( - Long brandId, + UUID brandId, String sort, Integer page, Integer size 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 fa0d9a283..09bed64f8 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 @@ -5,12 +5,13 @@ import org.springframework.data.domain.Pageable; import java.util.Optional; +import java.util.UUID; -public interface BrandJpaRepository extends JpaRepository { +public interface BrandJpaRepository extends JpaRepository { Optional findByName(String name); - Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByIdAndDeletedAtIsNull(UUID id); Page findAllByDeletedAtIsNull(Pageable pageable); 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 caed4ee21..e4ab5fb23 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 @@ -9,6 +9,7 @@ import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.UUID; @Repository @RequiredArgsConstructor @@ -24,7 +25,7 @@ public Brand save(Brand brand) { } @Override - public Optional findById(Long id) { + public Optional findById(UUID id) { return brandJpaRepository.findByIdAndDeletedAtIsNull(id) .map(BrandEntity::toDomain); } @@ -36,7 +37,7 @@ public Page findAll(Pageable pageable) { } @Override - public boolean existsById(Long id) { + public boolean existsById(UUID id) { return brandJpaRepository.existsById(id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java index f31d676a4..e438081e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java @@ -5,11 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.UUID; -public interface CategoryJpaRepository extends JpaRepository { - Optional findByIdAndDeletedAtIsNull(Long id); +public interface CategoryJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(UUID id); Page findAllByDeletedAtIsNull(Pageable pageable); - boolean existsByIdAndDeletedAtIsNull(Long id); + boolean existsByIdAndDeletedAtIsNull(UUID id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java index a0d1aa7ea..5dfd6b46b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.UUID; @Repository @RequiredArgsConstructor @@ -21,7 +22,7 @@ public Category save(Category category) { } @Override - public Optional findById(Long id) { + public Optional findById(UUID id) { return categoryJpaRepository.findByIdAndDeletedAtIsNull(id) .map(CategoryEntity::toDomain); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEntity.java new file mode 100644 index 000000000..f4130d315 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEntity.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupons") +public class CouponEntity extends BaseEntity { + + @Getter + @Column(nullable = false) + private String name; + + @Getter + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponType type; + + @Getter + @Column(nullable = false) + private int value; + + @Getter + @Column(name = "min_order_amount", nullable = false) + private int minOrderAmount; + + @Getter + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + protected CouponEntity() { + } + + public CouponEntity(String name, CouponType type, int value, int minOrderAmount, LocalDateTime expiredAt) { + this.name = name; + this.type = type; + this.value = value; + this.minOrderAmount = minOrderAmount; + this.expiredAt = expiredAt; + } + + public static CouponEntity from(Coupon coupon) { + return new CouponEntity(coupon.name(), coupon.type(), coupon.value(), coupon.minOrderAmount(), coupon.expiredAt()); + } + + public Coupon toDomain() { + return new Coupon(getId(), name, type, value, minOrderAmount, expiredAt); + } + + public void updateFrom(Coupon coupon) { + this.name = coupon.name(); + this.type = coupon.type(); + this.value = coupon.value(); + this.minOrderAmount = coupon.minOrderAmount(); + this.expiredAt = coupon.expiredAt(); + } +} 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..fa4bbd609 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.coupon; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface CouponJpaRepository extends JpaRepository { + List findAllByIdIn(List ids); + + Page findByDeletedAtIsNull(Pageable pageable); + + Optional findByIdAndDeletedAtIsNull(UUID id); +} 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..6bfb89942 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Coupon save(Coupon coupon) { + if (coupon.id() != null) { + Optional existing = couponJpaRepository.findByIdAndDeletedAtIsNull(coupon.id()); + if (existing.isPresent()) { + CouponEntity entity = existing.get(); + entity.updateFrom(coupon); + return couponJpaRepository.save(entity).toDomain(); + } + } + return couponJpaRepository.save(CouponEntity.from(coupon)).toDomain(); + } + + @Override + public Optional findById(UUID id) { + return couponJpaRepository.findByIdAndDeletedAtIsNull(id).map(CouponEntity::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return couponJpaRepository.findByDeletedAtIsNull(pageable).map(CouponEntity::toDomain); + } + + @Override + public List findAllByIdIn(List ids) { + return couponJpaRepository.findAllByIdIn(ids).stream().map(CouponEntity::toDomain).toList(); + } + + @Override + public Map findAllMapByIdIn(List ids) { + return couponJpaRepository.findAllByIdIn(ids).stream() + .collect(Collectors.toMap(CouponEntity::getId, CouponEntity::toDomain)); + } + + @Override + public void delete(Coupon coupon) { + Optional entity = couponJpaRepository.findByIdAndDeletedAtIsNull(coupon.id()); + if (entity.isPresent()) { + CouponEntity found = entity.get(); + found.delete(); + couponJpaRepository.save(found); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java new file mode 100644 index 000000000..df743abee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.IssuedCoupon; +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 java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "issued_coupons", uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "coupon_id"})) +public class IssuedCouponEntity extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private String memberId; + + @Column(name = "coupon_id", nullable = false) + private UUID couponId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponStatus status; + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + protected IssuedCouponEntity() { + } + + public IssuedCouponEntity(String memberId, UUID couponId, CouponStatus status, + LocalDateTime issuedAt, LocalDateTime expiredAt, LocalDateTime usedAt) { + this.memberId = memberId; + this.couponId = couponId; + this.status = status; + this.issuedAt = issuedAt; + this.expiredAt = expiredAt; + this.usedAt = usedAt; + } + + public static IssuedCouponEntity from(IssuedCoupon issuedCoupon) { + return new IssuedCouponEntity( + issuedCoupon.memberId(), + issuedCoupon.couponId(), + issuedCoupon.status(), + issuedCoupon.issuedAt(), + issuedCoupon.expiredAt(), + issuedCoupon.usedAt() + ); + } + + public IssuedCoupon toDomain() { + return new IssuedCoupon(memberId, couponId, status, issuedAt, expiredAt, usedAt); + } + + public void updateFrom(IssuedCoupon issuedCoupon) { + this.status = issuedCoupon.status(); + this.expiredAt = issuedCoupon.expiredAt(); + this.usedAt = issuedCoupon.usedAt(); + } + + public UUID getCouponId() { + return couponId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..2b3e9221e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponStatus; +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IssuedCouponJpaRepository extends JpaRepository { + Optional findByMemberIdAndCouponId(String memberId, UUID couponId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update IssuedCouponEntity ic + set ic.status = :usedStatus, + ic.usedAt = :usedAt + where ic.memberId = :memberId + and ic.couponId = :couponId + and ic.status = :availableStatus + and ic.expiredAt >= :now + """) + int markUsedAtomically( + @Param("memberId") String memberId, + @Param("couponId") UUID couponId, + @Param("availableStatus") CouponStatus availableStatus, + @Param("usedStatus") CouponStatus usedStatus, + @Param("now") LocalDateTime now, + @Param("usedAt") LocalDateTime usedAt + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update IssuedCouponEntity ic + set ic.status = :availableStatus, + ic.usedAt = null + where ic.memberId = :memberId + and ic.couponId = :couponId + and ic.status = :usedStatus + and ic.expiredAt >= :now + """) + int markAvailableAtomically( + @Param("memberId") String memberId, + @Param("couponId") UUID couponId, + @Param("availableStatus") CouponStatus availableStatus, + @Param("usedStatus") CouponStatus usedStatus, + @Param("now") LocalDateTime now + ); + + List findByMemberIdOrderByCreatedAtDesc(String memberId); + + Page findByCouponIdOrderByCreatedAtDesc(UUID couponId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..fb9d1f838 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { + + private final IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Override + public IssuedCoupon save(IssuedCoupon issuedCoupon) { + Optional existing = issuedCouponJpaRepository.findByMemberIdAndCouponId( + issuedCoupon.memberId(), issuedCoupon.couponId()); + + if (existing.isPresent()) { + IssuedCouponEntity entity = existing.get(); + entity.updateFrom(issuedCoupon); + return issuedCouponJpaRepository.save(entity).toDomain(); + } + + return issuedCouponJpaRepository.save(IssuedCouponEntity.from(issuedCoupon)).toDomain(); + } + + @Override + public Optional findByMemberIdAndCouponId(String memberId, UUID couponId) { + return issuedCouponJpaRepository.findByMemberIdAndCouponId(memberId, couponId) + .map(IssuedCouponEntity::toDomain); + } + + @Override + public int markUsedAtomically(String memberId, UUID couponId, LocalDateTime now) { + return issuedCouponJpaRepository.markUsedAtomically( + memberId, + couponId, + CouponStatus.AVAILABLE, + CouponStatus.USED, + now, + now + ); + } + + @Override + public int markAvailableAtomically(String memberId, UUID couponId, LocalDateTime now) { + return issuedCouponJpaRepository.markAvailableAtomically( + memberId, + couponId, + CouponStatus.AVAILABLE, + CouponStatus.USED, + now + ); + } + + @Override + public Page findByCouponId(UUID couponId, Pageable pageable) { + return issuedCouponJpaRepository.findByCouponIdOrderByCreatedAtDesc(couponId, pageable) + .map(IssuedCouponEntity::toDomain); + } + + @Override + public List findByMemberId(String memberId) { + return issuedCouponJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId) + .stream().map(IssuedCouponEntity::toDomain).toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java index 38cbe1ac0..26b0f558f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -6,6 +6,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.util.UUID; @Entity @Table(name = "likes", uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "product_id"})) @@ -15,12 +16,12 @@ public class LikeEntity extends BaseEntity { private String memberId; @Column(name = "product_id", nullable = false) - private Long productId; + private UUID productId; protected LikeEntity() { } - public LikeEntity(String memberId, Long productId) { + public LikeEntity(String memberId, UUID productId) { this.memberId = memberId; this.productId = productId; } @@ -37,7 +38,7 @@ public String getMemberId() { return memberId; } - public Long getProductId() { + public UUID getProductId() { return productId; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index 60c81c1bf..43dad0ce4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -5,14 +5,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.UUID; -public interface LikeJpaRepository extends JpaRepository { +public interface LikeJpaRepository extends JpaRepository { - boolean existsByMemberIdAndProductId(String memberId, Long productId); + boolean existsByMemberIdAndProductId(String memberId, UUID productId); - void deleteByMemberIdAndProductId(String memberId, Long productId); + void deleteByMemberIdAndProductId(String memberId, UUID productId); - void deleteByProductIdIn(List productIds); + void deleteByProductIdIn(List productIds); Page findByMemberIdOrderByCreatedAtDesc(String memberId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 5f3af03cf..85378eb79 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.UUID; @Repository @RequiredArgsConstructor @@ -22,17 +23,17 @@ public Like save(Like like) { } @Override - public boolean existsByMemberIdAndProductId(String memberId, Long productId) { + public boolean existsByMemberIdAndProductId(String memberId, UUID productId) { return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); } @Override - public void deleteByMemberIdAndProductId(String memberId, Long productId) { + public void deleteByMemberIdAndProductId(String memberId, UUID productId) { likeJpaRepository.deleteByMemberIdAndProductId(memberId, productId); } @Override - public void deleteByProductIds(List productIds) { + public void deleteByProductIds(List productIds) { likeJpaRepository.deleteByProductIdIn(productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java index ba9701aad..f0dd9e10e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -2,8 +2,9 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.UUID; -public interface MemberJpaRepository extends JpaRepository { +public interface MemberJpaRepository extends JpaRepository { Optional findByMemberId(String memberId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java index 836110438..1340bb325 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -8,6 +8,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.UUID; @RequiredArgsConstructor @Repository @@ -22,7 +23,7 @@ public Optional findByMemberId(MemberId memberId) { } @Override - public Optional findDbIdByMemberId(MemberId memberId) { + public Optional findDbIdByMemberId(MemberId memberId) { return memberJpaRepository.findByMemberId(memberId.value()) .map(com.loopers.infrastructure.member.MemberEntity::getId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java index d84d15502..4d75de763 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -15,14 +15,15 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Entity @Table(name = "orders") public class OrderEntity extends BaseEntity { - @Column(name = "user_id", nullable = false) + @Column(name = "member_id", nullable = false) @Getter - private Long userId; + private String memberId; @Column(name = "order_number", nullable = false, unique = true) @Getter @@ -41,26 +42,32 @@ public class OrderEntity extends BaseEntity { @Getter private int totalAmount; + @Column(name = "coupon_id") + @Getter + private UUID couponId; + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List items = new ArrayList<>(); protected OrderEntity() {} - public OrderEntity(Long userId, String orderNumber, ZonedDateTime orderDate, OrderStatus status, int totalAmount) { - this.userId = userId; + public OrderEntity(String memberId, String orderNumber, ZonedDateTime orderDate, OrderStatus status, int totalAmount, UUID couponId) { + this.memberId = memberId; this.orderNumber = orderNumber; this.orderDate = orderDate; this.status = status; this.totalAmount = totalAmount; + this.couponId = couponId; } public static OrderEntity from(Order order) { return new OrderEntity( - order.userId(), + order.memberId(), order.orderNumber(), order.orderDate(), order.status(), - order.totalAmount() + order.totalAmount(), + order.couponId() ); } @@ -75,11 +82,12 @@ public void cancel() { public Order toDomain() { return new Order( getId(), - userId, + memberId, orderNumber, orderDate, status, totalAmount, + couponId, items.stream().map(OrderItemEntity::toDomain).toList(), getDeletedAt() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java index c8347338a..e051ea645 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -11,23 +11,25 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; +import java.util.UUID; @Entity @Table(name = "order_items") public class OrderItemEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "BINARY(16)", nullable = false, updatable = false) @Getter - private Long id; + private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) private OrderEntity order; - @Column(name = "product_id") @Getter - private Long productId; + @Column(name = "product_id", columnDefinition = "BINARY(16)") + private UUID productId; @Column(nullable = false) @Getter 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 dba8dad3c..2ea312b08 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 @@ -7,14 +7,15 @@ import org.springframework.data.repository.query.Param; import java.time.ZonedDateTime; +import java.util.UUID; -public interface OrderJpaRepository extends JpaRepository { +public interface OrderJpaRepository extends JpaRepository { - @Query("SELECT o FROM OrderEntity o WHERE o.userId = :userId " + + @Query("SELECT o FROM OrderEntity o WHERE o.memberId = :memberId " + "AND o.orderDate >= :startAt AND o.orderDate <= :endAt " + "AND o.deletedAt IS NULL") - Page findByUserIdAndOrderDateBetween( - @Param("userId") Long userId, + Page findByMemberIdAndOrderDateBetween( + @Param("memberId") String memberId, @Param("startAt") ZonedDateTime startAt, @Param("endAt") ZonedDateTime endAt, Pageable pageable @@ -24,5 +25,5 @@ Page findByUserIdAndOrderDateBetween( Page findAllActive(Pageable pageable); @Query("SELECT COUNT(i) > 0 FROM OrderItemEntity i WHERE i.productId = :productId") - boolean existsByProductId(@Param("productId") Long productId); + boolean existsByProductId(@Param("productId") UUID productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 9bdb584c5..b3525d808 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -10,6 +10,7 @@ import java.time.ZonedDateTime; import java.util.Optional; +import java.util.UUID; @Repository @RequiredArgsConstructor @@ -35,15 +36,15 @@ public Order save(Order order) { } @Override - public Optional findById(Long id) { + public Optional findById(UUID id) { return orderJpaRepository.findById(id) .filter(e -> e.getDeletedAt() == null) .map(OrderEntity::toDomain); } @Override - public Page findByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable) { - return orderJpaRepository.findByUserIdAndOrderDateBetween(userId, startAt, endAt, pageable) + public Page findByMemberId(String memberId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable) { + return orderJpaRepository.findByMemberIdAndOrderDateBetween(memberId, startAt, endAt, pageable) .map(OrderEntity::toDomain); } @@ -54,7 +55,7 @@ public Page findAll(Pageable pageable) { } @Override - public boolean existsOrderItemByProductId(Long productId) { + public boolean existsOrderItemByProductId(UUID productId) { return orderJpaRepository.existsByProductId(productId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index bd4134869..af3317e5e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -5,6 +5,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import java.util.UUID; @Entity @Table(name = "products") @@ -23,10 +24,10 @@ public class ProductEntity extends BaseEntity { private String description; @Column(name = "category_id", nullable = false) - private Long categoryId; + private UUID categoryId; @Column(name = "brand_id", nullable = false) - private Long brandId; + private UUID brandId; @Column(name = "like_count", nullable = false) private Integer likeCount; @@ -39,8 +40,8 @@ public ProductEntity( Integer price, Integer stock, String description, - Long categoryId, - Long brandId, + UUID categoryId, + UUID brandId, Integer likeCount ) { this.name = name; 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 eadc7a2bc..d12820b04 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,35 +1,59 @@ package com.loopers.infrastructure.product; -import jakarta.persistence.LockModeType; 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; +import java.util.UUID; -public interface ProductJpaRepository extends JpaRepository { +public interface ProductJpaRepository extends JpaRepository { - Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByIdAndDeletedAtIsNull(UUID id); + + List findAllByIdInAndDeletedAtIsNullOrderByIdAsc(List ids); Page findAllByDeletedAtIsNull(Pageable pageable); - Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + Page findAllByBrandIdAndDeletedAtIsNull(UUID brandId, Pageable pageable); - Page findAllByBrandId(Long brandId, Pageable pageable); + Page findAllByBrandId(UUID brandId, Pageable pageable); @Query("SELECT p.id FROM ProductEntity p WHERE p.brandId = :brandId AND p.deletedAt IS NULL") - List findIdsByBrandIdAndDeletedAtIsNull(@Param("brandId") Long brandId); - - @Modifying - @Query(value = "UPDATE products p SET p.deleted_at = CURRENT_TIMESTAMP WHERE p.brand_id = :brandId AND p.deleted_at IS NULL", nativeQuery = true) - int softDeleteByBrandId(@Param("brandId") Long brandId); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM ProductEntity p WHERE p.id IN :ids AND p.deletedAt IS NULL") - List findAllByIdInWithLock(@Param("ids") List ids); + List findIdsByBrandIdAndDeletedAtIsNull(@Param("brandId") UUID brandId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update ProductEntity p + set p.deletedAt = CURRENT_TIMESTAMP + where p.brandId = :brandId + and p.deletedAt is null +""") + int softDeleteByBrandId(@Param("brandId") UUID brandId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update ProductEntity p + set p.likeCount = case + when p.likeCount + :delta < 0 then 0 + else p.likeCount + :delta + end + where p.id = :productId + and p.deletedAt is null + """) + int updateLikeCount(@Param("productId") UUID productId, @Param("delta") long delta); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update ProductEntity p + set p.stock = p.stock - :quantity + where p.id = :productId + and p.deletedAt is null + and p.stock >= :quantity + """) + int decreaseStockAtomically(@Param("productId") UUID productId, @Param("quantity") int quantity); } 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 c24315072..f60b8d534 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 @@ -9,6 +9,7 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; @Repository @RequiredArgsConstructor @@ -35,19 +36,27 @@ public Product save(Product product) { } @Override - public Optional findById(Long id) { + public Optional findById(UUID id) { return productJpaRepository.findByIdAndDeletedAtIsNull(id) .map(ProductEntity::toDomain); } @Override - public Optional findByIdIncludingDeleted(Long id) { + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNullOrderByIdAsc(ids) + .stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public Optional findByIdIncludingDeleted(UUID id) { return productJpaRepository.findById(id) .map(ProductEntity::toDomain); } @Override - public Page findAll(Long brandId, Pageable pageable) { + public Page findAll(UUID brandId, Pageable pageable) { if (brandId == null) { return productJpaRepository.findAllByDeletedAtIsNull(pageable).map(ProductEntity::toDomain); } @@ -55,7 +64,7 @@ public Page findAll(Long brandId, Pageable pageable) { } @Override - public Page findAllIncludingDeleted(Long brandId, Pageable pageable) { + public Page findAllIncludingDeleted(UUID brandId, Pageable pageable) { if (brandId == null) { return productJpaRepository.findAll(pageable).map(ProductEntity::toDomain); } @@ -63,19 +72,23 @@ public Page findAllIncludingDeleted(Long brandId, Pageable pageable) { } @Override - public List findIdsByBrandId(Long brandId) { + public List findIdsByBrandId(UUID brandId) { return productJpaRepository.findIdsByBrandIdAndDeletedAtIsNull(brandId); } @Override - public void softDeleteByBrandId(Long brandId) { + public int updateLikeCount(UUID productId, long delta) { + return productJpaRepository.updateLikeCount(productId, delta); + } + + @Override + public void softDeleteByBrandId(UUID brandId) { productJpaRepository.softDeleteByBrandId(brandId); } @Override - public List findAllByIdInWithLock(List ids) { - return productJpaRepository.findAllByIdInWithLock(ids) - .stream().map(ProductEntity::toDomain).toList(); + public int decreaseStockAtomically(UUID productId, int quantity) { + return productJpaRepository.decreaseStockAtomically(productId, quantity); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java index e56b17f36..d93cc0012 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminBrandController.java @@ -12,6 +12,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -41,14 +42,14 @@ public ApiResponse createBrand( } @GetMapping("/{brandId}") - public ApiResponse getBrand(@PathVariable Long brandId) { + public ApiResponse getBrand(@PathVariable UUID brandId) { Brand brand = brandApplicationService.findById(brandId); return ApiResponse.success(BrandDto.BrandResponse.from(brand)); } @PutMapping("/{brandId}") public ApiResponse updateBrand( - @PathVariable Long brandId, + @PathVariable UUID brandId, @Valid @RequestBody BrandDto.UpdateBrandRequest request ) { Brand brand = brandApplicationService.update(brandId, request.toCommand()); @@ -56,7 +57,7 @@ public ApiResponse updateBrand( } @DeleteMapping("/{brandId}") - public ApiResponse deleteBrand(@PathVariable Long brandId) { + public ApiResponse deleteBrand(@PathVariable UUID brandId) { brandAdminFacade.delete(brandId); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java index 3c3c102bd..ec57b69fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCategoryController.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.admin; -import com.loopers.application.category.CategoryApplicationService; +import com.loopers.application.coupon.category.CategoryApplicationService; import com.loopers.domain.category.Category; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.category.CategoryDto; @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequiredArgsConstructor @RequestMapping("/api-admin/v1/categories") @@ -33,7 +35,7 @@ public ApiResponse listCategories( @GetMapping("/{categoryId}") public ApiResponse getCategory( - @PathVariable Long categoryId + @PathVariable UUID categoryId ) { Category category = categoryApplicationService.findById(categoryId); return ApiResponse.success(CategoryDto.CategoryResponse.from(category)); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCouponController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCouponController.java new file mode 100644 index 000000000..4ffa2a351 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminCouponController.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.coupon.CouponAdminApplicationService; +import com.loopers.domain.coupon.Coupon; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.coupon.CouponAdminDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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; + +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api-admin/v1/coupons") +public class AdminCouponController { + + private final CouponAdminApplicationService couponAdminApplicationService; + + @GetMapping + public ApiResponse listCoupons( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + Page coupons = couponAdminApplicationService.list(pageable); + return ApiResponse.success(CouponAdminDto.CouponListResponse.from(coupons)); + } + + @GetMapping("/{couponId}") + public ApiResponse getCoupon(@PathVariable UUID couponId) { + Coupon coupon = couponAdminApplicationService.findById(couponId); + return ApiResponse.success(CouponAdminDto.CouponResponse.from(coupon)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createCoupon( + @Valid @RequestBody CouponAdminDto.CreateCouponRequest request + ) { + Coupon coupon = couponAdminApplicationService.create(request.toCommand()); + return ApiResponse.success(CouponAdminDto.CouponResponse.from(coupon)); + } + + @PutMapping("/{couponId}") + public ApiResponse updateCoupon( + @PathVariable UUID couponId, + @Valid @RequestBody CouponAdminDto.UpdateCouponRequest request + ) { + Coupon coupon = couponAdminApplicationService.update(couponId, request.toCommand()); + return ApiResponse.success(CouponAdminDto.CouponResponse.from(coupon)); + } + + @DeleteMapping("/{couponId}") + public ApiResponse deleteCoupon(@PathVariable UUID couponId) { + couponAdminApplicationService.delete(couponId); + return ApiResponse.success(); + } + + @GetMapping("/{couponId}/issues") + public ApiResponse listCouponIssues( + @PathVariable UUID couponId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size); + return ApiResponse.success( + CouponAdminDto.CouponIssueListResponse.from(couponAdminApplicationService.listIssues(couponId, pageable)) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java index d9334e80c..7bcf359ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminOrderController.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.admin; import com.loopers.application.order.OrderApplicationService; -import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderUseCase; import com.loopers.application.order.query.OrderAccessRequest; import com.loopers.domain.order.Order; import com.loopers.interfaces.api.ApiResponse; @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -23,7 +24,7 @@ public class AdminOrderController { private final OrderApplicationService orderApplicationService; - private final OrderFacade orderFacade; + private final OrderUseCase orderUseCase; @GetMapping public ApiResponse listOrders( @@ -37,7 +38,7 @@ public ApiResponse listOrders( @GetMapping("/{orderId}") public ApiResponse getOrder( - @PathVariable Long orderId + @PathVariable UUID orderId ) { Order order = orderApplicationService.getById(new OrderAccessRequest(orderId, null, true)); return ApiResponse.success(OrderDto.OrderResponse.from(order)); @@ -45,9 +46,9 @@ public ApiResponse getOrder( @PatchMapping("/{orderId}/cancel") public ApiResponse cancelOrder( - @PathVariable Long orderId + @PathVariable UUID orderId ) { - Order order = orderFacade.cancel(new OrderAccessRequest(orderId, null, true)); + Order order = orderUseCase.cancel(new OrderAccessRequest(orderId, null, true)); return ApiResponse.success(OrderDto.OrderResponse.from(order)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java index d4ec6f728..5b88dbc76 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/AdminProductController.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -37,7 +38,7 @@ public ApiResponse listProducts(ProductListQuery } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { + public ApiResponse getProduct(@PathVariable UUID productId) { return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.getIncludingDeleted(productId))); } @@ -52,7 +53,7 @@ public ApiResponse createProduct( @PutMapping("/{productId}") public ApiResponse updateProduct( - @PathVariable Long productId, + @PathVariable UUID productId, @Valid @RequestBody ProductDto.UpdateProductRequest request ) { Product updated = productApplicationService.update(productId, request.toCommand()); @@ -60,7 +61,7 @@ public ApiResponse updateProduct( } @DeleteMapping("/{productId}") - public ApiResponse deleteProduct(@PathVariable Long productId) { + public ApiResponse deleteProduct(@PathVariable UUID productId) { productAdminFacade.delete(productId); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index 64ae340c5..f18653316 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -15,7 +16,7 @@ public class BrandController { private final BrandApplicationService brandApplicationService; @GetMapping("/{brandId}") - public ApiResponse getBrand(@PathVariable Long brandId) { + public ApiResponse getBrand(@PathVariable UUID brandId) { Brand brand = brandApplicationService.findById(brandId); return ApiResponse.success(BrandDto.BrandResponse.from(brand)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java index a3361d9cc..caf120b5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Page; import java.util.List; +import java.util.UUID; public class BrandDto { @@ -27,11 +28,7 @@ public String toString() { } public CreateBrandCommand toCommand() { - return CreateBrandCommand.builder() - .name(name) - .description(description) - .imageUrl(imageUrl) - .build(); + return new CreateBrandCommand(name, description, imageUrl); } } @@ -47,27 +44,19 @@ public String toString() { } public UpdateBrandCommand toCommand() { - return UpdateBrandCommand.builder() - .description(description) - .imageUrl(imageUrl) - .build(); + return new UpdateBrandCommand(description, imageUrl); } } @Builder public record BrandResponse( - Long id, + UUID id, String name, String description, String imageUrl ) { public static BrandResponse from(Brand brand) { - return BrandResponse.builder() - .id(brand.id()) - .name(brand.name().value()) - .description(brand.description()) - .imageUrl(brand.imageUrl()) - .build(); + return new BrandResponse(brand.id(), brand.name().value(), brand.description(), brand.imageUrl()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java index 7256b6a62..5e98e6b56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/category/CategoryDto.java @@ -4,11 +4,12 @@ import org.springframework.data.domain.Page; import java.util.List; +import java.util.UUID; public class CategoryDto { public record CategoryResponse( - Long id, + UUID id, String name ) { public static CategoryResponse from(Category category) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminDto.java new file mode 100644 index 000000000..4dd3a8aca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminDto.java @@ -0,0 +1,129 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.command.CreateCouponCommand; +import com.loopers.application.coupon.command.UpdateCouponCommand; +import com.loopers.application.coupon.view.CouponIssueView; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public class CouponAdminDto { + + public record CreateCouponRequest( + @NotBlank(message = "쿠폰 이름은 필수입니다") + String name, + @NotNull(message = "쿠폰 타입은 필수입니다") + CouponType type, + @Min(value = 1, message = "쿠폰 값은 1 이상이어야 합니다") + int value, + @Min(value = 0, message = "최소 주문 금액은 0 이상이어야 합니다") + int minOrderAmount, + @NotNull(message = "만료 일시는 필수입니다") + LocalDateTime expiredAt + ) { + public CreateCouponCommand toCommand() { + return new CreateCouponCommand(name, type, value, minOrderAmount, expiredAt); + } + } + + public record UpdateCouponRequest( + @NotBlank(message = "쿠폰 이름은 필수입니다") + String name, + @NotNull(message = "쿠폰 타입은 필수입니다") + CouponType type, + @Min(value = 1, message = "쿠폰 값은 1 이상이어야 합니다") + int value, + @Min(value = 0, message = "최소 주문 금액은 0 이상이어야 합니다") + int minOrderAmount, + @NotNull(message = "만료 일시는 필수입니다") + LocalDateTime expiredAt + ) { + public UpdateCouponCommand toCommand() { + return new UpdateCouponCommand(name, type, value, minOrderAmount, expiredAt); + } + } + + public record CouponResponse( + UUID id, + String name, + CouponType type, + int value, + int minOrderAmount, + LocalDateTime expiredAt + ) { + public static CouponResponse from(Coupon coupon) { + return new CouponResponse( + coupon.id(), + coupon.name(), + coupon.type(), + coupon.value(), + coupon.minOrderAmount(), + coupon.expiredAt() + ); + } + } + + public record CouponListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static CouponListResponse from(Page pageData) { + return new CouponListResponse( + pageData.getContent().stream().map(CouponResponse::from).toList(), + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } + + public record CouponIssueResponse( + UUID couponId, + String memberId, + CouponStatus status, + LocalDateTime issuedAt, + LocalDateTime expiredAt, + LocalDateTime usedAt + ) { + public static CouponIssueResponse from(CouponIssueView view) { + return new CouponIssueResponse( + view.couponId(), + view.memberId(), + view.status(), + view.issuedAt(), + view.expiredAt(), + view.usedAt() + ); + } + } + + public record CouponIssueListResponse( + List items, + int page, + int size, + long totalElements, + int totalPages + ) { + public static CouponIssueListResponse from(Page pageData) { + return new CouponIssueListResponse( + pageData.getContent().stream().map(CouponIssueResponse::from).toList(), + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java new file mode 100644 index 000000000..6ebcafc16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.CouponApplicationService; +import com.loopers.application.coupon.view.MyCouponView; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthMember; +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.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class CouponController { + + private final CouponApplicationService couponApplicationService; + + @PostMapping("/coupons/{couponId}/issue") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse issue(@PathVariable UUID couponId, @AuthMember Member member) { + couponApplicationService.issue(couponId, member.id().value()); + return ApiResponse.success(); + } + + @GetMapping("/users/me/coupons") + public ApiResponse listMyCoupons(@AuthMember Member member) { + List views = couponApplicationService.listMyCoupons(member.id().value()); + return ApiResponse.success(CouponDto.MyCouponListResponse.from(views)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java new file mode 100644 index 000000000..afd26d825 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.application.coupon.view.MyCouponView; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public class CouponDto { + + public record MyCouponResponse( + UUID couponId, + String name, + String type, + int value, + int minOrderAmount, + LocalDateTime expiredAt, + String status + ) { + public static MyCouponResponse from(MyCouponView view) { + return new MyCouponResponse( + view.couponId(), + view.name(), + view.type().name(), + view.value(), + view.minOrderAmount(), + view.expiredAt(), + view.status().name() + ); + } + } + + public record MyCouponListResponse( + List items + ) { + public static MyCouponListResponse from(List views) { + return new MyCouponListResponse(views.stream().map(MyCouponResponse::from).toList()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index c33439720..9892a87b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -1,12 +1,11 @@ package com.loopers.interfaces.api.order; import com.loopers.application.order.OrderApplicationService; -import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderUseCase; import com.loopers.domain.order.Order; import com.loopers.domain.member.Member; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.auth.AuthMember; -import com.loopers.application.member.MemberAuthenticationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -20,6 +19,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.loopers.application.order.query.OrderAccessRequest; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -27,8 +27,7 @@ public class OrderController { private final OrderApplicationService orderApplicationService; - private final OrderFacade orderFacade; - private final MemberAuthenticationService memberAuthenticationService; + private final OrderUseCase orderUseCase; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -36,28 +35,25 @@ public ApiResponse createOrder( @AuthMember Member member, @Valid @RequestBody OrderDto.CreateOrderRequest request ) { - Long userId = memberAuthenticationService.findDbIdByMember(member); - Order order = orderFacade.create(request.toCommand(userId)); + Order order = orderUseCase.create(request.toCommand(member.id().value())); return ApiResponse.success(OrderDto.OrderResponse.from(order)); } @PatchMapping("/{orderId}/cancel") public ApiResponse cancelOrder( @AuthMember Member member, - @PathVariable Long orderId + @PathVariable UUID orderId ) { - Long userId = memberAuthenticationService.findDbIdByMember(member); - Order order = orderFacade.cancel(new OrderAccessRequest(orderId, userId, false)); + Order order = orderUseCase.cancel(new OrderAccessRequest(orderId, member.id().value(), false)); return ApiResponse.success(OrderDto.OrderResponse.from(order)); } @GetMapping("/{orderId}") public ApiResponse getOrder( @AuthMember Member member, - @PathVariable Long orderId + @PathVariable UUID orderId ) { - Long userId = memberAuthenticationService.findDbIdByMember(member); - Order order = orderApplicationService.getById(new OrderAccessRequest(orderId, userId, false)); + Order order = orderApplicationService.getById(new OrderAccessRequest(orderId, member.id().value(), false)); return ApiResponse.success(OrderDto.OrderResponse.from(order)); } @@ -66,8 +62,7 @@ public ApiResponse listOrders( @AuthMember Member member, @Valid OrderDto.ListOrdersRequest request ) { - Long userId = memberAuthenticationService.findDbIdByMember(member); - Page orders = orderApplicationService.listByUser(request.toQuery(userId)); + Page orders = orderApplicationService.listByUser(request.toQuery(member.id().value())); return ApiResponse.success(OrderDto.OrderListResponse.from(orders)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java index a488a8a94..764635586 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java @@ -16,32 +16,38 @@ import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.List; +import java.util.UUID; public class OrderDto { public record CreateOrderRequest( @NotEmpty(message = "주문 항목은 1개 이상이어야 합니다") @Valid - List items + List items, + UUID couponId ) { - public CreateOrderCommand toCommand(Long userId) { + public CreateOrderRequest(List items) { + this(items, null); + } + + public CreateOrderCommand toCommand(String memberId) { List itemCommands = items.stream() .map(i -> new CreateOrderCommand.OrderItemCommand(i.productId(), i.quantity())) .toList(); - return new CreateOrderCommand(userId, itemCommands); + return new CreateOrderCommand(memberId, itemCommands, couponId); } } public record OrderItemRequest( @NotNull(message = "상품 ID는 필수입니다") - Long productId, + UUID productId, @Min(value = 1, message = "수량은 1 이상이어야 합니다") int quantity ) {} public record OrderItemResponse( - Long id, - Long productId, + UUID id, + UUID productId, int quantity, String snapshotProductName, int snapshotPrice, @@ -60,22 +66,24 @@ public static OrderItemResponse from(OrderItem item) { } public record OrderResponse( - Long id, - Long userId, + UUID id, + String memberId, String orderNumber, ZonedDateTime orderDate, String status, int totalAmount, + UUID couponId, List items ) { public static OrderResponse from(Order order) { return new OrderResponse( order.id(), - order.userId(), + order.memberId(), order.orderNumber(), order.orderDate(), order.status().name(), order.totalAmount(), + order.couponId(), order.items().stream().map(OrderItemResponse::from).toList() ); } @@ -112,11 +120,11 @@ public record ListOrdersRequest( private static final int DEFAULT_PAGE = 0; private static final int DEFAULT_SIZE = 20; - public OrderListByUserRequest toQuery(Long userId) { + public OrderListByUserRequest toQuery(String memberId) { int resolvedPage = page == null ? DEFAULT_PAGE : page; int resolvedSize = size == null ? DEFAULT_SIZE : size; Pageable pageable = PageRequest.of(resolvedPage, resolvedSize); - return new OrderListByUserRequest(userId, startAt, endAt, pageable); + return new OrderListByUserRequest(memberId, startAt, endAt, pageable); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java index 8efa2589b..78e3a7415 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/LikeController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; @RestController @RequestMapping("/api/v1") @@ -29,13 +30,13 @@ public class LikeController { @PostMapping("/products/{productId}/likes") @ResponseStatus(HttpStatus.CREATED) - public ApiResponse registerLike(@PathVariable Long productId, @AuthMember Member member) { + public ApiResponse registerLike(@PathVariable UUID productId, @AuthMember Member member) { likeFacade.register(productId, member); return ApiResponse.success(); } @DeleteMapping("/products/{productId}/likes") - public ApiResponse cancelLike(@PathVariable Long productId, @AuthMember Member member) { + public ApiResponse cancelLike(@PathVariable UUID productId, @AuthMember Member member) { likeFacade.cancel(productId, member); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index 16f62abe9..404fae6f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; @RestController @RequiredArgsConstructor @@ -34,7 +35,7 @@ public ApiResponse createProduct( } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { + public ApiResponse getProduct(@PathVariable UUID productId) { return ApiResponse.success(ProductDto.ProductResponse.from(productQueryFacade.get(productId))); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java index f8b9345a7..fc80069de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -12,6 +12,7 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.UUID; public class ProductDto { @@ -26,9 +27,9 @@ public record CreateProductRequest( Integer stock, String description, @NotNull(message = "카테고리 ID는 필수입니다") - Long categoryId, + UUID categoryId, @NotNull(message = "브랜드 ID는 필수입니다") - Long brandId + UUID brandId ) { public CreateProductCommand toCommand() { return new CreateProductCommand(name, price, stock, description, categoryId, brandId); @@ -36,13 +37,13 @@ public CreateProductCommand toCommand() { } public record ProductResponse( - Long id, + UUID id, String name, Integer price, Integer stock, String description, - Long categoryId, - Long brandId, + UUID categoryId, + UUID brandId, BrandInfo brand, Integer likeCount, ZonedDateTime deletedAt @@ -83,7 +84,7 @@ public static ProductResponse from(Product product, String brandName) { } public record BrandInfo( - Long id, + UUID id, String name ) { } @@ -99,8 +100,8 @@ public record UpdateProductRequest( Integer stock, String description, @NotNull(message = "카테고리 ID는 필수입니다") - Long categoryId, - Long brandId + UUID categoryId, + UUID brandId ) { public UpdateProductCommand toCommand() { return new UpdateProductCommand(name, price, stock, description, categoryId, brandId); diff --git a/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java index a485bafe8..bc5acc953 100644 --- a/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java @@ -1,9 +1,14 @@ package com.loopers; +import com.loopers.testcontainers.MySqlTestContainersConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") class CommerceApiContextTest { @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..01a2ef0d4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class BrandApplicationServiceIntegrationTest { + + @Autowired + private BrandApplicationService brandApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("BrandApplicationService 통합: 생성 후 조회 가능") + void createAndFind() { + Brand created = brandApplicationService.create(new CreateBrandCommand("브랜드통합", "desc", "img")); + + Brand found = brandApplicationService.findById(created.id()); + + assertThat(found.id()).isEqualTo(created.id()); + assertThat(found.name().value()).isEqualTo("브랜드통합"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java deleted file mode 100644 index 6bc6c1197..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.application.brand.command.CreateBrandCommand; -import com.loopers.application.brand.command.UpdateBrandCommand; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.brand.vo.BrandName; -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 org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.InOrder; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BrandApplicationServiceTest { - - @Mock - private BrandRepository brandRepository; - - @InjectMocks - private BrandApplicationService brandApplicationService; - - @Nested - @DisplayName("브랜드 등록") - class CreateBrandTest { - - @Test - @DisplayName("CreateBrandCommand 생성 성공") - void createCommandCreation() { - String name = "퍼피박스"; - String description = "강아지용품 브랜드"; - String imageUrl = "https://example.com/brand.png"; - - CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); - - assertThat(command.name()).isEqualTo(name); - assertThat(command.description()).isEqualTo(description); - assertThat(command.imageUrl()).isEqualTo(imageUrl); - } - - @Test - @DisplayName("CreateBrandCommand - description null 허용") - void createCommandWithNullDescription() { - String name = "퍼피박스"; - String description = null; - String imageUrl = "https://example.com/brand.png"; - - CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); - - assertThat(command.description()).isNull(); - } - - @Test - @DisplayName("CreateBrandCommand - imageUrl null 허용") - void createCommandWithNullImageUrl() { - String name = "퍼피박스"; - String description = "설명"; - String imageUrl = null; - - CreateBrandCommand command = new CreateBrandCommand(name, description, imageUrl); - - assertThat(command.imageUrl()).isNull(); - } - } - - @Nested - @DisplayName("브랜드 수정") - class UpdateBrandTest { - - @Test - @DisplayName("UpdateBrandCommand 생성 - description만 변경") - void updateCommandDescriptionOnly() { - String description = "새로운 설명"; - String imageUrl = null; - - UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); - - assertThat(command.description()).isEqualTo(description); - assertThat(command.imageUrl()).isNull(); - } - - @Test - @DisplayName("UpdateBrandCommand 생성 - imageUrl만 변경") - void updateCommandImageUrlOnly() { - String description = null; - String imageUrl = "https://new.com/brand.png"; - - UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); - - assertThat(command.description()).isNull(); - assertThat(command.imageUrl()).isEqualTo(imageUrl); - } - - @Test - @DisplayName("UpdateBrandCommand 생성 - 둘 다 변경") - void updateCommandBoth() { - String description = "새로운 설명"; - String imageUrl = "https://new.com/brand.png"; - - UpdateBrandCommand command = new UpdateBrandCommand(description, imageUrl); - - assertThat(command.description()).isEqualTo(description); - assertThat(command.imageUrl()).isEqualTo(imageUrl); - } - } - - @Nested - @DisplayName("브랜드 삭제") - class DeleteTest { - - @Test - @DisplayName("브랜드 삭제 성공") - void deleteSuccess() { - Brand target = new Brand(1L, new BrandName("퍼피박스"), "설명", "https://example.com/brand.png"); - - when(brandRepository.findById(1L)).thenReturn(Optional.of(target)); - doNothing().when(brandRepository).delete(target); - - brandApplicationService.delete(1L); - - verify(brandRepository).findById(1L); - verify(brandRepository).delete(target); - } - - @Test - @DisplayName("삭제 대상이 없으면 404 반환") - void deleteWhenNotFound() { - when(brandRepository.findById(99L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> brandApplicationService.delete(99L)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); - - verify(brandRepository).findById(99L); - verify(brandRepository, never()).delete(any(Brand.class)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..aa0559a86 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponAdminApplicationServiceIntegrationTest.java @@ -0,0 +1,54 @@ +package com.loopers.application.coupon; + +import com.loopers.application.coupon.command.CreateCouponCommand; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponType; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class CouponAdminApplicationServiceIntegrationTest { + + @Autowired + private CouponAdminApplicationService couponAdminApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("CouponAdminApplicationService 통합: 생성 후 목록/상세 조회 가능") + void createListFind() { + Coupon created = couponAdminApplicationService.create(new CreateCouponCommand( + "쿠폰통합", + CouponType.FIXED, + 3000, + 10000, + LocalDateTime.now().plusDays(5) + )); + + var page = couponAdminApplicationService.list(PageRequest.of(0, 20)); + Coupon found = couponAdminApplicationService.findById(created.id()); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(found.id()).isEqualTo(created.id()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponApplicationServiceTest.java new file mode 100644 index 000000000..818d9f263 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponApplicationServiceTest.java @@ -0,0 +1,45 @@ +package com.loopers.application.coupon; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Coupon Application Service 통합 테스트") +class CouponApplicationServiceTest { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("CouponApplicationService 빈이 등록되어야 한다") + void couponApplicationServiceBeanShouldExist() { + try { + Class appServiceClass = Class.forName("com.loopers.application.coupon.CouponApplicationService"); + Object bean = applicationContext.getBean(appServiceClass); + assertThat(bean).isNotNull(); + } catch (ClassNotFoundException e) { + fail("RED: CouponApplicationService 클래스가 아직 구현되지 않았습니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUseConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUseConcurrencyTest.java new file mode 100644 index 000000000..7a662c2c0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponUseConcurrencyTest.java @@ -0,0 +1,236 @@ +package com.loopers.application.coupon; + +import com.loopers.application.coupon.command.UseCouponCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.infrastructure.coupon.CouponEntity; +import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberDto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +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 org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; +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) +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Coupon 사용 동시성 테스트") +class CouponUseConcurrencyTest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String TEST_LOGIN_ID = "couponuseuser1"; + private static final String TEST_PASSWORD = "Test1234!@"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private CouponJpaRepository couponJpaRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private CouponApplicationService couponApplicationService; + + private UUID couponId; + private UUID productId; + + @BeforeEach + void setUp() { + MemberDto.RegisterRequest registerRequest = new MemberDto.RegisterRequest( + TEST_LOGIN_ID, + TEST_PASSWORD, + "쿠폰유저", + "19900101", + "coupon.use.concurrent@test.com", + "010-3333-4444" + ); + + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + UUID categoryId = categoryRepository.save(new Category("쿠폰동시성카테고리")).id(); + UUID brandId = brandRepository.save(new Brand(new BrandName("쿠폰동시성브랜드"), "desc", "img")).id(); + productId = productRepository.save(new Product("쿠폰동시성상품", 10000, 100, "desc", categoryId, brandId)).id(); + + CouponEntity couponEntity = couponJpaRepository.saveAndFlush( + new CouponEntity("동시성쿠폰", CouponType.FIXED, 1000, 0, LocalDateTime.now().plusDays(1)) + ); + couponId = couponEntity.getId(); + assertThat(couponId).isNotNull(); + + issuedCouponRepository.save(new IssuedCoupon( + TEST_LOGIN_ID, + couponId, + CouponStatus.AVAILABLE, + LocalDateTime.now(), + LocalDateTime.now().plusDays(1), + null + )); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("동일 couponId로 동시 주문 시 1건만 성공해야 한다") + void sameCouponConcurrentOrder() throws Exception { + int threadCount = 8; + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + Map statusCounts = new ConcurrentHashMap<>(); + + List tasks = java.util.stream.IntStream.range(0, threadCount) + .mapToObj(i -> (Runnable) () -> { + try { + startLatch.await(); + Map body = Map.of( + "items", List.of(Map.of("productId", productId, "quantity", 1)), + "couponId", couponId + ); + + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(body, authHeaders()), + new ParameterizedTypeReference>>() {} + ); + + if (response.getStatusCode() == HttpStatus.CREATED) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + statusCounts.computeIfAbsent(response.getStatusCode().value(), ignored -> new AtomicInteger(0)) + .incrementAndGet(); + } catch (Exception ignored) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }) + .toList(); + + tasks.forEach(executorService::submit); + startLatch.countDown(); + doneLatch.await(); + executorService.shutdown(); + + assertThat(successCount.get()) + .withFailMessage("statusCounts=%s", statusCounts) + .isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + } + + @Test + @DisplayName("동일 couponId 사용 취소 동시 요청 시 1건만 성공해야 한다") + void sameCouponConcurrentCancelUse() throws Exception { + couponApplicationService.use(new UseCouponCommand(couponId, TEST_LOGIN_ID, 10000)); + + int threadCount = 8; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger conflictCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + startLatch.await(); + couponApplicationService.cancelUse(couponId, TEST_LOGIN_ID); + successCount.incrementAndGet(); + } catch (CoreException e) { + if (e.getErrorType() == ErrorType.CONFLICT) { + conflictCount.incrementAndGet(); + } else { + failureCount.incrementAndGet(); + } + } catch (Exception e) { + failureCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executorService.shutdown(); + + assertThat(successCount.get()).isEqualTo(1); + assertThat(conflictCount.get()).isEqualTo(threadCount - 1); + assertThat(failureCount.get()).isZero(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, TEST_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, TEST_PASSWORD); + return headers; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/category/CategoryApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/category/CategoryApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..46186655c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/category/CategoryApplicationServiceIntegrationTest.java @@ -0,0 +1,47 @@ +package com.loopers.application.coupon.category; + +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class CategoryApplicationServiceIntegrationTest { + + @Autowired + private CategoryApplicationService categoryApplicationService; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("CategoryApplicationService 통합: 목록 조회 가능") + void list() { + categoryRepository.save(new Category("카테고리통합")); + + var page = categoryApplicationService.list(PageRequest.of(0, 20)); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().get(0).name()).isEqualTo("카테고리통합"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..415190ba2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceIntegrationTest.java @@ -0,0 +1,39 @@ +package com.loopers.application.like; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.UUID; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class LikeApplicationServiceIntegrationTest { + + @Autowired + private LikeApplicationService likeApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("LikeApplicationService 통합: 등록 후 assertLiked 통과") + void registerAndAssertLiked() { + UUID productId = UUID.randomUUID(); + + likeApplicationService.register("likemember", productId); + likeApplicationService.assertLiked("likemember", productId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..68f57862a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceIntegrationTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.RegisterCommand; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class MemberApplicationServiceIntegrationTest { + + @Autowired + private MemberApplicationService memberApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("MemberApplicationService 통합: 가입 후 중복 체크 true") + void registerAndDuplicateCheck() { + memberApplicationService.register(new RegisterCommand( + "memberinteg1", + "Test1234!@", + "테스터", + "memberinteg@test.com", + "19900101", + "010-1111-2222" + )); + + boolean duplicated = memberApplicationService.checkDuplicateLoginId("memberinteg1"); + + assertThat(duplicated).isTrue(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java deleted file mode 100644 index 33a7ff8ad..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberApplicationServiceTest.java +++ /dev/null @@ -1,211 +0,0 @@ -package com.loopers.application.member; - -import com.loopers.application.member.command.ChangePasswordCommand; -import com.loopers.application.member.command.RegisterCommand; -import com.loopers.domain.member.PasswordEncoder; -import com.loopers.domain.member.Member; -import com.loopers.domain.member.MemberRepository; -import com.loopers.domain.member.vo.BirthDate; -import com.loopers.domain.member.vo.Email; -import com.loopers.domain.member.vo.Name; -import com.loopers.domain.member.vo.Password; -import com.loopers.domain.member.vo.Phone; -import com.loopers.domain.member.vo.MemberId; -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 org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class MemberApplicationServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private MemberApplicationService memberApplicationService; - - private MemberId memberId; - private Name name; - private Email email; - private BirthDate birthDate; - private Phone phone; - private Member member; - - @BeforeEach - void setUp() { - memberId = new MemberId("testmember"); - name = new Name("홍길동"); - email = new Email("test@example.com"); - birthDate = new BirthDate(LocalDate.of(1999, 1, 15)); - phone = new Phone("010-1234-5678"); - member = new Member(memberId, Password.ofEncoded("$2a$10$encodedPassword"), name, email, birthDate, phone); - } - - @Nested - @DisplayName("회원가입") - class RegisterTest { - - @Test - @DisplayName("성공") - void registerSuccess() { - RegisterCommand command = RegisterCommand.builder() - .memberId("testmember") - .rawPassword("1Q2w3e4r!") - .name("홍길동") - .email("test@example.com") - .birthDate("19990115") - .phone("010-1234-5678") - .build(); - Member encodedMember = new Member(memberId, Password.ofEncoded("$2a$10$encodedPassword"), name, email, birthDate, phone); - - when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(false); - when(passwordEncoder.encode("1Q2w3e4r!")).thenReturn("$2a$10$encodedPassword"); - when(memberRepository.save(encodedMember)).thenReturn(encodedMember); - - Member result = memberApplicationService.register(command); - - assertThat(result.id().value()).isEqualTo("testmember"); - assertThat(result.password().value()).isEqualTo("$2a$10$encodedPassword"); - assertThat(result.phone().value()).isEqualTo("010-1234-5678"); - } - - @Test - @DisplayName("실패 - 로그인 ID 중복") - void registerFailDuplicateMemberId() { - RegisterCommand command = RegisterCommand.builder() - .memberId("testmember") - .rawPassword("1Q2w3e4r!") - .name("홍길동") - .email("test@example.com") - .birthDate("19990115") - .phone("010-1234-5678") - .build(); - when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(true); - - assertThatThrownBy(() -> memberApplicationService.register(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); - verify(memberRepository, never()).save(any(Member.class)); - } - - @Test - @DisplayName("실패 - 저장 시점 중복") - void registerFailDuplicateMemberIdAtSave() { - RegisterCommand command = RegisterCommand.builder() - .memberId("testmember") - .rawPassword("1Q2w3e4r!") - .name("홍길동") - .email("test@example.com") - .birthDate("19990115") - .phone("010-1234-5678") - .build(); - when(memberRepository.existsByMemberId(any(MemberId.class))).thenReturn(false); - when(passwordEncoder.encode("1Q2w3e4r!")).thenReturn("$2a$10$encodedPassword"); - when(memberRepository.save(any(Member.class))).thenThrow(new DataIntegrityViolationException("duplicate key")); - - assertThatThrownBy(() -> memberApplicationService.register(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); - } - } - - @Nested - @DisplayName("로그인 ID 중복 검사") - class DuplicateCheckTest { - - @Test - @DisplayName("사용 가능한 아이디 - false 반환") - void checkDuplicateLoginId_available() { - when(memberRepository.existsByMemberId(memberId)).thenReturn(false); - - boolean result = memberApplicationService.checkDuplicateLoginId("testmember"); - - assertThat(result).isFalse(); - } - - @Test - @DisplayName("이미 사용 중인 아이디 - true 반환") - void checkDuplicateLoginId_unavailable() { - when(memberRepository.existsByMemberId(memberId)).thenReturn(true); - - boolean result = memberApplicationService.checkDuplicateLoginId("testmember"); - - assertThat(result).isTrue(); - } - } - - @Nested - @DisplayName("비밀번호 변경") - class ChangePasswordTest { - - @Test - @DisplayName("성공") - void changePasswordSuccess() { - ChangePasswordCommand command = ChangePasswordCommand.builder() - .memberId(memberId) - .newRawPassword("New1234!@") - .build(); - Member updatedMember = new Member(memberId, Password.ofEncoded("$2a$10$newEncodedPassword"), name, email, birthDate, phone); - - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("New1234!@", "$2a$10$encodedPassword")).thenReturn(false); - when(passwordEncoder.encode("New1234!@")).thenReturn("$2a$10$newEncodedPassword"); - - assertThatNoException().isThrownBy(() -> memberApplicationService.changePassword(command)); - verify(memberRepository).save(updatedMember); - } - - @Test - @DisplayName("실패 - 존재하지 않는 사용자") - void changePasswordFailMemberNotFound() { - ChangePasswordCommand command = ChangePasswordCommand.builder() - .memberId(memberId) - .newRawPassword("New1234!@") - .build(); - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> memberApplicationService.changePassword(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); - verify(memberRepository, never()).save(any(Member.class)); - } - - @Test - @DisplayName("실패 - 새 비밀번호가 기존과 동일") - void changePasswordFailSamePassword() { - ChangePasswordCommand command = ChangePasswordCommand.builder() - .memberId(memberId) - .newRawPassword("same") - .build(); - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("same", "$2a$10$encodedPassword")).thenReturn(true); - - assertThatThrownBy(() -> memberApplicationService.changePassword(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - verify(memberRepository, never()).save(any(Member.class)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceIntegrationTest.java new file mode 100644 index 000000000..7cfc59c31 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceIntegrationTest.java @@ -0,0 +1,56 @@ +package com.loopers.application.member; + +import com.loopers.application.member.command.AuthenticateCommand; +import com.loopers.application.member.command.RegisterCommand; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class MemberAuthenticationServiceIntegrationTest { + + @Autowired + private MemberApplicationService memberApplicationService; + + @Autowired + private MemberAuthenticationService memberAuthenticationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("MemberAuthenticationService 통합: 가입 후 인증 성공") + void authenticate() { + memberApplicationService.register(new RegisterCommand( + "memberinteg2", + "Test1234!@", + "테스터", + "memberinteg2@test.com", + "19900101", + "010-1111-2223" + )); + + Member authenticated = memberAuthenticationService.authenticate( + new AuthenticateCommand(new MemberId("memberinteg2"), "Test1234!@") + ); + + assertThat(authenticated.id().value()).isEqualTo("memberinteg2"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java deleted file mode 100644 index 9e9cc914d..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberAuthenticationServiceTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.loopers.application.member; - -import com.loopers.application.member.command.AuthenticateCommand; -import com.loopers.domain.member.PasswordEncoder; -import com.loopers.domain.member.Member; -import com.loopers.domain.member.MemberRepository; -import com.loopers.domain.member.vo.BirthDate; -import com.loopers.domain.member.vo.Email; -import com.loopers.domain.member.vo.Name; -import com.loopers.domain.member.vo.Password; -import com.loopers.domain.member.vo.Phone; -import com.loopers.domain.member.vo.MemberId; -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 org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class MemberAuthenticationServiceTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private MemberAuthenticationService memberAuthenticationService; - - private MemberId memberId; - private Member member; - - @BeforeEach - void setUp() { - memberId = new MemberId("testmember"); - member = new Member( - memberId, - Password.ofEncoded("$2a$10$dummyEncodedPasswordForTest"), - new Name("홍길동"), - new Email("test@example.com"), - new BirthDate(LocalDate.of(1999, 1, 15)), - new Phone("010-1234-5678") - ); - } - - @Nested - @DisplayName("인증") - class AuthenticateTest { - - @Test - @DisplayName("성공") - void authenticateSuccess() { - AuthenticateCommand command = AuthenticateCommand.builder() - .memberId(memberId) - .rawPassword("1Q2w3e4r!") - .build(); - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("1Q2w3e4r!", member.password().value())).thenReturn(true); - - Member result = memberAuthenticationService.authenticate(command); - - assertThat(result.id()).isEqualTo(memberId); - } - - @Test - @DisplayName("실패 - 존재하지 않는 사용자") - void authenticateFailMemberNotFound() { - AuthenticateCommand command = AuthenticateCommand.builder() - .memberId(memberId) - .rawPassword("1Q2w3e4r!") - .build(); - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> memberAuthenticationService.authenticate(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); - } - - @Test - @DisplayName("실패 - 비밀번호 불일치") - void authenticateFailWrongPassword() { - AuthenticateCommand command = AuthenticateCommand.builder() - .memberId(memberId) - .rawPassword("wrongPassword") - .build(); - when(memberRepository.findByMemberId(memberId)).thenReturn(Optional.of(member)); - when(passwordEncoder.matches("wrongPassword", member.password().value())).thenReturn(false); - - assertThatThrownBy(() -> memberAuthenticationService.authenticate(command)) - .isInstanceOf(CoreException.class) - .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..49d04397f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceIntegrationTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.order; + +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class OrderApplicationServiceIntegrationTest { + + @Autowired + private OrderApplicationService orderApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("OrderApplicationService 통합: 주문 생성 후 조회 가능") + void createAndGet() { + UUID productId = UUID.randomUUID(); + OrderItem item = new OrderItem(productId, 1, "상품명", 12000, "브랜드명"); + Order created = orderApplicationService.create("ordermember", List.of(item), null); + + Order found = orderApplicationService.getById(new OrderAccessRequest(created.id(), "ordermember", false)); + + assertThat(found.id()).isEqualTo(created.id()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java deleted file mode 100644 index f6e7c81bb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.application.order.query.OrderAccessRequest; -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.order.OrderStatus; -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 org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class OrderApplicationServiceTest { - - @Mock - private OrderRepository orderRepository; - - @InjectMocks - private OrderApplicationService orderApplicationService; - private static final OrderItem SAMPLE_ORDER_ITEM = new OrderItem(1L, 2, "강아지 사료", 10000, "퍼피박스"); - - @Nested - @DisplayName("주문 생성") - class Create { - - @Test - @DisplayName("유효한 주문 항목으로 주문 생성 성공") - void createOrderSuccess() { - List items = List.of(SAMPLE_ORDER_ITEM); - when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0)); - - Order result = orderApplicationService.create(1L, items); - - assertThat(result.status()).isEqualTo(OrderStatus.ORDERED); - assertThat(result.userId()).isEqualTo(1L); - assertThat(result.items()).hasSize(1); - } - - @Test - @DisplayName("주문 항목이 비어있으면 400 예외가 발생한다") - void emptyItemsFails() { - List items = List.of(); - - assertThatThrownBy(() -> orderApplicationService.create(1L, items)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - } - } - - @Nested - @DisplayName("주문 취소") - class Cancel { - - @Test - @DisplayName("이미 취소된 주문 재취소는 409 예외가 발생한다") - void cancelAlreadyCancelledOrderFails() { - Order cancelledOrder = new Order( - 1L, 1L, "ORDER-001", - java.time.ZonedDateTime.now(), - OrderStatus.CANCELLED, - 10000, - List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), - null - ); - - when(orderRepository.findById(1L)).thenReturn(Optional.of(cancelledOrder)); - - assertThatThrownBy(() -> orderApplicationService.cancel(new OrderAccessRequest(1L, 1L, false))) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.CONFLICT)); - } - - @Test - @DisplayName("타인의 주문 취소 시 403 예외가 발생한다") - void cancelOthersOrderFails() { - Order order = new Order( - 1L, 2L, "ORDER-001", - java.time.ZonedDateTime.now(), - OrderStatus.ORDERED, - 10000, - List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), - null - ); - - when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - - assertThatThrownBy(() -> orderApplicationService.cancel(new OrderAccessRequest(1L, 1L, false))) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); - } - - @Test - @DisplayName("존재하지 않는 주문 취소 시 404 예외가 발생한다") - void cancelNonExistentOrderFails() { - when(orderRepository.findById(anyLong())).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> orderApplicationService.cancel(new OrderAccessRequest(99L, 1L, false))) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); - } - } - - @Nested - @DisplayName("주문 상세 조회") - class GetById { - - @Test - @DisplayName("타인의 주문 조회 시 403 예외가 발생한다") - void getOthersOrderFails() { - Order order = new Order( - 1L, 2L, "ORDER-001", - java.time.ZonedDateTime.now(), - OrderStatus.ORDERED, - 10000, - List.of(new com.loopers.domain.order.OrderItem(1L, 1L, 1L, 1, "사료", 10000, "퍼피박스")), - null - ); - - when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - - assertThatThrownBy(() -> orderApplicationService.getById(new OrderAccessRequest(1L, 1L, false))) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.FORBIDDEN)); - } - - @Test - @DisplayName("존재하지 않는 주문 조회 시 404 예외가 발생한다") - void getNonExistentOrderFails() { - when(orderRepository.findById(anyLong())).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> orderApplicationService.getById(new OrderAccessRequest(99L, 1L, false))) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..5e8fae850 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryApplicationServiceIntegrationTest.java @@ -0,0 +1,49 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class OrderQueryApplicationServiceIntegrationTest { + + @Autowired + private OrderApplicationService orderApplicationService; + + @Autowired + private OrderQueryApplicationService orderQueryApplicationService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("OrderQueryApplicationService 통합: 주문 아이템 productId 존재 여부 조회") + void existsOrderItemByProductId() { + UUID productId = UUID.randomUUID(); + OrderItem item = new OrderItem(productId, 1, "상품명", 12000, "브랜드명"); + orderApplicationService.create("orderquerymember", List.of(item), null); + + boolean exists = orderQueryApplicationService.existsOrderItemByProductId(productId); + + assertThat(exists).isTrue(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderUseCaseIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderUseCaseIntegrationTest.java new file mode 100644 index 000000000..3e9bbfa88 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderUseCaseIntegrationTest.java @@ -0,0 +1,193 @@ +package com.loopers.application.order; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.application.coupon.CouponAdminApplicationService; +import com.loopers.application.coupon.command.CreateCouponCommand; +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.application.order.query.OrderAccessRequest; +import com.loopers.application.product.ProductApplicationService; +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.product.Product; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class OrderUseCaseIntegrationTest { + + @Autowired + private OrderUseCase orderUseCase; + + @Autowired + private ProductApplicationService productApplicationService; + + @Autowired + private BrandApplicationService brandApplicationService; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private CouponAdminApplicationService couponAdminApplicationService; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("OrderUseCase 통합: 주문 생성 시 재고 차감 반영") + void createOrderAndDecreaseStock() { + UUID categoryId = categoryRepository.save(new Category("주문카테고리")).id(); + UUID brandId = brandApplicationService.create(new CreateBrandCommand("주문브랜드", "desc", "img")).id(); + Product product = productApplicationService.create(new CreateProductCommand( + "주문상품", + 5000, + 5, + "desc", + categoryId, + brandId + )); + + Order order = orderUseCase.create(new CreateOrderCommand( + "orderusemember", + List.of(new CreateOrderCommand.OrderItemCommand(product.id(), 2)), + null + )); + + Product updated = productApplicationService.get(product.id()); + assertThat(order.id()).isNotNull(); + assertThat(updated.stock()).isEqualTo(3); + } + + @Test + @DisplayName("OrderUseCase 통합: 주문 취소 시 쿠폰이 복구되어 재사용 가능") + void cancelOrderRestoresCoupon() { + String memberId = "ordercouponmember"; + UUID categoryId = categoryRepository.save(new Category("주문카테고리2")).id(); + UUID brandId = brandApplicationService.create(new CreateBrandCommand("주문브랜드2", "desc", "img")).id(); + Product product = productApplicationService.create(new CreateProductCommand( + "주문상품2", + 5000, + 10, + "desc", + categoryId, + brandId + )); + + Coupon coupon = couponAdminApplicationService.create(new CreateCouponCommand( + "주문취소복구쿠폰", + CouponType.FIXED, + 1000, + 0, + LocalDateTime.now().plusDays(3) + )); + + issuedCouponRepository.save(new IssuedCoupon( + memberId, + coupon.id(), + CouponStatus.AVAILABLE, + LocalDateTime.now(), + LocalDateTime.now().plusDays(3), + null + )); + + Order firstOrder = orderUseCase.create(new CreateOrderCommand( + memberId, + List.of(new CreateOrderCommand.OrderItemCommand(product.id(), 1)), + coupon.id() + )); + + orderUseCase.cancel(new OrderAccessRequest(firstOrder.id(), memberId, false)); + + Order secondOrder = orderUseCase.create(new CreateOrderCommand( + memberId, + List.of(new CreateOrderCommand.OrderItemCommand(product.id(), 1)), + coupon.id() + )); + + Product updated = productApplicationService.get(product.id()); + assertThat(secondOrder.id()).isNotNull(); + assertThat(updated.stock()).isEqualTo(9); + } + + @Test + @DisplayName("OrderUseCase 통합: 관리자 취소에서도 쿠폰 복구가 동작한다") + void adminCancelAlsoRestoresCoupon() { + String memberId = "ordercouponadmin"; + UUID categoryId = categoryRepository.save(new Category("주문카테고리3")).id(); + UUID brandId = brandApplicationService.create(new CreateBrandCommand("주문브랜드3", "desc", "img")).id(); + Product product = productApplicationService.create(new CreateProductCommand( + "주문상품3", + 5000, + 10, + "desc", + categoryId, + brandId + )); + + Coupon coupon = couponAdminApplicationService.create(new CreateCouponCommand( + "관리자취소복구쿠폰", + CouponType.FIXED, + 1000, + 0, + LocalDateTime.now().plusDays(3) + )); + + issuedCouponRepository.save(new IssuedCoupon( + memberId, + coupon.id(), + CouponStatus.AVAILABLE, + LocalDateTime.now(), + LocalDateTime.now().plusDays(3), + null + )); + + Order firstOrder = orderUseCase.create(new CreateOrderCommand( + memberId, + List.of(new CreateOrderCommand.OrderItemCommand(product.id(), 1)), + coupon.id() + )); + + orderUseCase.cancel(new OrderAccessRequest(firstOrder.id(), null, true)); + + Order secondOrder = orderUseCase.create(new CreateOrderCommand( + memberId, + List.of(new CreateOrderCommand.OrderItemCommand(product.id(), 1)), + coupon.id() + )); + + Product updated = productApplicationService.get(product.id()); + assertThat(secondOrder.id()).isNotNull(); + assertThat(updated.stock()).isEqualTo(9); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java deleted file mode 100644 index a32733307..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/LikeServiceTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.Product; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("Like Domain Tests") -class LikeServiceTest { - - private static final Product ACTIVE_PRODUCT = new Product( - 1L, - "간식", - 12_000, - 10, - "닭가슴살 간식", - 1L, - 1L, - 3, - null - ); - - @Nested - @DisplayName("좋아요 카운트") - class LikeCount { - - @Test - @DisplayName("좋아요 등록이면 likeCount가 1 증가한다") - void increaseLikeCount_whenRegisterLike_thenLikeCountIncreasesByOne() { - Product result = ACTIVE_PRODUCT.increaseLikeCount(); - assertThat(result.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() + 1); - } - - @Test - @DisplayName("좋아요가 중복 등록되어도 증가 로직은 호출 횟수만큼 반영된다") - void increaseLikeCount_whenRegisteredTwice_thenLikeCountIncreasesTwice() { - Product once = ACTIVE_PRODUCT.increaseLikeCount(); - Product twice = once.increaseLikeCount(); - - assertThat(twice.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() + 2); - } - - @Test - @DisplayName("좋아요 취소하면 likeCount가 1 감소한다") - void decreaseLikeCount_whenCancelLike_thenLikeCountDecreasesByOne() { - Product result = ACTIVE_PRODUCT.decreaseLikeCount(); - - assertThat(result.likeCount()).isEqualTo(ACTIVE_PRODUCT.likeCount() - 1); - } - - @Test - @DisplayName("이미 0인 likeCount는 취소 시 음수로 내려가지 않는다") - void decreaseLikeCount_whenLikeCountZero_thenKeepsZero() { - Product zeroLiked = new Product( - 2L, - "우산", - 20_000, - 5, - "산책 우산", - 1L, - 1L, - 0, - null - ); - - Product result = zeroLiked.decreaseLikeCount(); - assertThat(result.likeCount()).isEqualTo(0); - } - - @Test - @DisplayName("삭제 상품이면 삭제 상태를 판단할 수 있다") - void isDeleted_whenDeletedAtExists_thenReturnsTrue() { - Product deletedProduct = new Product( - 3L, - "하네스", - 15_000, - 3, - "산책 하네스", - 1L, - 1L, - 5, - ZonedDateTime.now() - ); - - assertThat(deletedProduct.isDeleted()).isTrue(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..d6fd5b3d3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceIntegrationTest.java @@ -0,0 +1,65 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.brand.command.CreateBrandCommand; +import com.loopers.application.product.command.CreateProductCommand; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductApplicationServiceIntegrationTest { + + @Autowired + private ProductApplicationService productApplicationService; + + @Autowired + private BrandApplicationService brandApplicationService; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("ProductApplicationService 통합: 생성 후 조회 가능") + void createAndGet() { + UUID categoryId = categoryRepository.save(new Category("상품카테고리")).id(); + UUID brandId = brandApplicationService.create(new CreateBrandCommand("상품브랜드", "desc", "img")).id(); + + Product created = productApplicationService.create(new CreateProductCommand( + "상품통합", + 10000, + 10, + "desc", + categoryId, + brandId + )); + + Product found = productApplicationService.get(created.id()); + + assertThat(found.id()).isEqualTo(created.id()); + assertThat(found.name()).isEqualTo("상품통합"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java deleted file mode 100644 index 769a5f161..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.application.product.command.CreateProductCommand; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.brand.vo.BrandName; -import com.loopers.domain.category.Category; -import com.loopers.domain.category.CategoryRepository; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -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 org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; - -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class ProductApplicationServiceTest { - - @Mock - private ProductRepository productRepository; - @Mock - private BrandRepository brandRepository; - @Mock - private CategoryRepository categoryRepository; - - @InjectMocks - private ProductApplicationService productApplicationService; - - @Nested - @DisplayName("상품 등록") - class CreateTest { - - @Test - @DisplayName("성공") - void createSuccess() { - CreateProductCommand command = new CreateProductCommand( - "강아지 사료", - 10000, - 20, - "소형견용", - 1L, - 1L - ); - when(brandRepository.findById(1L)).thenReturn(Optional.of(new Brand(1L, new BrandName("퍼피박스"), "", ""))); - when(categoryRepository.findById(1L)).thenReturn(Optional.of(new Category(1L, "푸드"))); - - Product saved = new Product(1L, "강아지 사료", 10000, 20, "소형견용", 1L, 1L, 0, null); - when(productRepository.save(any(Product.class))).thenReturn(saved); - - Product result = productApplicationService.create(command); - - assertThat(result.id()).isEqualTo(1L); - assertThat(result.name()).isEqualTo("강아지 사료"); - verify(productRepository).save(any(Product.class)); - } - - @Test - @DisplayName("실패 - 브랜드가 존재하지 않음") - void createFailWhenBrandNotFound() { - CreateProductCommand command = new CreateProductCommand( - "강아지 사료", - 10000, - 20, - "소형견용", - 1L, - 1L - ); - - when(brandRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> productApplicationService.create(command)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - verify(productRepository, never()).save(any(Product.class)); - } - - @Test - @DisplayName("실패 - 카테고리가 존재하지 않음") - void createFailWhenCategoryNotFound() { - CreateProductCommand command = new CreateProductCommand( - "강아지 사료", - 10000, - 20, - "소형견용", - 1L, - 1L - ); - - when(brandRepository.findById(1L)).thenReturn(Optional.of(new Brand(1L, new BrandName("퍼피박스"), "", ""))); - when(categoryRepository.findById(1L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> productApplicationService.create(command)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - verify(productRepository, never()).save(any(Product.class)); - } - - @Test - @DisplayName("실패 - 카테고리 ID 누락") - void createFailWhenCategoryIdMissing() { - CreateProductCommand command = new CreateProductCommand( - "강아지 사료", - 10000, - 20, - "소형견용", - null, - 10L - ); - - assertThatThrownBy(() -> productApplicationService.create(command)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - verify(productRepository, never()).save(any(Product.class)); - } - - @Test - @DisplayName("실패 - 브랜드 ID 누락") - void createFailWhenBrandIdMissing() { - CreateProductCommand command = new CreateProductCommand( - "강아지 사료", - 10000, - 20, - "소형견용", - 1L, - null - ); - - assertThatThrownBy(() -> productApplicationService.create(command)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); - verify(productRepository, never()).save(any(Product.class)); - } - } - - @Nested - @DisplayName("상품 조회") - class GetTest { - - @Test - @DisplayName("실패 - 존재하지 않는 상품") - void getFailNotFound() { - when(productRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> productApplicationService.get(999L)) - .isInstanceOf(CoreException.class) - .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); - } - } - - @Nested - @DisplayName("상품 목록 조회") - class ListTest { - - @Test - @DisplayName("브랜드 필터로 조회한다") - void listByBrand() { - PageRequest pageable = PageRequest.of(0, 20); - Page page = new PageImpl<>(List.of( - new Product(1L, "A", 1000, 5, "d1", 1L, 10L, 0, null) - )); - when(productRepository.findAll(10L, pageable)).thenReturn(page); - - Page result = productApplicationService.list(10L, pageable); - - assertThat(result.getTotalElements()).isEqualTo(1); - assertThat(result.getContent().get(0).brandId()).isEqualTo(10L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeAplicationServiceConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeAplicationServiceConcurrencyTest.java new file mode 100644 index 000000000..b72e516f5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductLikeAplicationServiceConcurrencyTest.java @@ -0,0 +1,116 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.UUID; +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 +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductLikeAplicationServiceConcurrencyTest { + + private final ProductLikeAplicationService productLikeAplicationService; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + ProductLikeAplicationServiceConcurrencyTest( + ProductLikeAplicationService productLikeAplicationService, + ProductRepository productRepository, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.productLikeAplicationService = productLikeAplicationService; + this.productRepository = productRepository; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("좋아요 카운트 증가를 동시에 요청하면 요청 수만큼 정확히 증가한다") + void increaseLikeCount_concurrentRequests_increasesExactly() throws InterruptedException { + UUID brandId = brandRepository.save(new Brand(new BrandName("LIKE_CONC_BRAND"), "", "")).id(); + UUID categoryId = categoryRepository.save(new Category("LIKE_CONC_CATEGORY")).id(); + UUID productId = productRepository.save(new Product("동시성 증가 상품", 10_000, 10, "desc", categoryId, brandId)).id(); + + int threadCount = 50; + AtomicInteger failures = runConcurrently(threadCount, () -> productLikeAplicationService.increaseLikeCount(productId)); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(failures.get()).isZero(); + assertThat(updated.likeCount()).isEqualTo(threadCount); + } + + @Test + @DisplayName("좋아요 카운트 감소를 동시에 요청해도 0 미만으로 내려가지 않는다") + void decreaseLikeCount_concurrentRequests_neverGoesBelowZero() throws InterruptedException { + UUID brandId = brandRepository.save(new Brand(new BrandName("LIKE_CONC_BRAND_2"), "", "")).id(); + UUID categoryId = categoryRepository.save(new Category("LIKE_CONC_CATEGORY_2")).id(); + UUID productId = productRepository.save(new Product(null, "동시성 감소 상품", 10_000, 10, "desc", categoryId, brandId, 5, null)).id(); + + AtomicInteger failures = runConcurrently(30, () -> productLikeAplicationService.decreaseLikeCount(productId)); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(failures.get()).isZero(); + assertThat(updated.likeCount()).isZero(); + } + + private AtomicInteger runConcurrently(int threadCount, Runnable action) throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger failures = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + ready.countDown(); + try { + start.await(); + action.run(); + } catch (Exception e) { + failures.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + ready.await(3, TimeUnit.SECONDS); + start.countDown(); + done.await(10, TimeUnit.SECONDS); + executorService.shutdownNow(); + return failures; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java deleted file mode 100644 index 6987da34c..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.application.product; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -@Disabled("Scaffold: enable after Product module implementation is added") -@DisplayName("Product Application Service Tests") -class ProductServiceTest { - - @Nested - @DisplayName("상품 등록") - class Register { - - @Test - @DisplayName("상품등록_성공") - void registerSuccess() { - assertThat(true).isTrue(); - } - - @Test - @DisplayName("상품등록_브랜드미존재_예외") - void registerFailWhenBrandNotFound() { - assertThat(true).isTrue(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductStockApplicationServiceConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductStockApplicationServiceConcurrencyTest.java new file mode 100644 index 000000000..b89bcb9a4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductStockApplicationServiceConcurrencyTest.java @@ -0,0 +1,315 @@ +package com.loopers.application.product; + +import com.loopers.application.order.command.CreateOrderCommand; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.vo.BrandName; +import com.loopers.domain.category.Category; +import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.UUID; +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 java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductStockApplicationServiceConcurrencyTest { + + private final ProductStockApplicationService productStockApplicationService; + private final ProductLikeAplicationService productLikeAplicationService; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final CategoryRepository categoryRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + ProductStockApplicationServiceConcurrencyTest( + ProductStockApplicationService productStockApplicationService, + ProductLikeAplicationService productLikeAplicationService, + ProductRepository productRepository, + BrandRepository brandRepository, + CategoryRepository categoryRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.productStockApplicationService = productStockApplicationService; + this.productLikeAplicationService = productLikeAplicationService; + this.productRepository = productRepository; + this.brandRepository = brandRepository; + this.categoryRepository = categoryRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("원자 업데이트 재고 차감: 재고 10, 요청 20 동시 실행 시 10건만 성공한다") + void atomicStrategy_concurrency() throws InterruptedException { + UUID productId = createStockProduct(10); + StockConcurrencyMetrics metrics = runConcurrentDeduction(productId, 20, + ignored -> productStockApplicationService.decreaseStockWithAtomicUpdate(productId, 1)); + printMetrics("ATOMIC_UPDATE", metrics); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(metrics.failureCount()).isZero(); + assertThat(metrics.successCount()).isEqualTo(10); + assertThat(metrics.outOfStockCount()).isEqualTo(10); + assertThat(updated.stock()).isZero(); + } + + @Test + @DisplayName("좋아요(원자 업데이트) + 원자 재고 차감을 동시에 요청하면 좋아요는 모두 성공하고 재고는 10건만 차감된다") + void mixedLikeAndStock_atomicStockStrategy_concurrency() throws InterruptedException { + UUID productId = createStockProduct(10); + MixedLikeAndStockMetrics metrics = runConcurrentLikeAndStock( + productId, + 20, + 20, + () -> productStockApplicationService.decreaseStockWithAtomicUpdate(productId, 1) + ); + printMixedMetrics("ATOMIC_UPDATE", metrics); + + Product updated = productRepository.findById(productId).orElseThrow(); + assertThat(metrics.likeFailureCount()).isZero(); + assertThat(metrics.likeSuccessCount()).isEqualTo(20); + assertThat(metrics.stockFailureCount()).isZero(); + assertThat(metrics.stockSuccessCount()).isEqualTo(10); + assertThat(metrics.stockOutOfStockCount()).isEqualTo(10); + assertThat(updated.likeCount()).isEqualTo(20); + assertThat(updated.stock()).isZero(); + } + + private UUID createStockProduct(int stock) { + UUID brandId = brandRepository.save(new Brand(new BrandName("STOCK_CONC_BRAND"), "", "")).id(); + UUID categoryId = categoryRepository.save(new Category("STOCK_CONC_CATEGORY")).id(); + return productRepository.save(new Product("동시성 재고 상품", 10_000, stock, "desc", categoryId, brandId)).id(); + } + + private StockConcurrencyMetrics runConcurrentDeduction(UUID productId, int threadCount, Consumer strategy) + throws InterruptedException { + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger outOfStockCount = new AtomicInteger(0); + AtomicInteger conflictCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + + long startedAtNanos = System.nanoTime(); + for (int i = 0; i < threadCount; i++) { + int index = i; + executorService.execute(() -> { + ready.countDown(); + try { + start.await(); + strategy.accept(index); + successCount.incrementAndGet(); + } catch (CoreException e) { + if (e.getErrorType() == ErrorType.BAD_REQUEST) { + outOfStockCount.incrementAndGet(); + } else if (e.getErrorType() == ErrorType.CONFLICT) { + conflictCount.incrementAndGet(); + } else { + failureCount.incrementAndGet(); + } + } catch (Exception e) { + failureCount.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + ready.await(3, TimeUnit.SECONDS); + start.countDown(); + done.await(15, TimeUnit.SECONDS); + long elapsedNanos = System.nanoTime() - startedAtNanos; + executorService.shutdownNow(); + + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(elapsedNanos); + double throughput = elapsedNanos == 0L ? 0D : (successCount.get() * 1_000_000_000D) / elapsedNanos; + + return new StockConcurrencyMetrics( + productId, + threadCount, + successCount.get(), + outOfStockCount.get(), + conflictCount.get(), + failureCount.get(), + elapsedMillis, + throughput + ); + } + + private MixedLikeAndStockMetrics runConcurrentLikeAndStock( + UUID productId, + int likeRequestCount, + int stockRequestCount, + Runnable stockAction + ) throws InterruptedException { + AtomicInteger likeSuccessCount = new AtomicInteger(0); + AtomicInteger likeFailureCount = new AtomicInteger(0); + AtomicInteger stockSuccessCount = new AtomicInteger(0); + AtomicInteger stockOutOfStockCount = new AtomicInteger(0); + AtomicInteger stockConflictCount = new AtomicInteger(0); + AtomicInteger stockFailureCount = new AtomicInteger(0); + + int totalThreadCount = likeRequestCount + stockRequestCount; + ExecutorService executorService = Executors.newFixedThreadPool(totalThreadCount); + CountDownLatch ready = new CountDownLatch(totalThreadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(totalThreadCount); + + long startedAtNanos = System.nanoTime(); + + for (int i = 0; i < likeRequestCount; i++) { + executorService.execute(() -> { + ready.countDown(); + try { + start.await(); + productLikeAplicationService.increaseLikeCount(productId); + likeSuccessCount.incrementAndGet(); + } catch (Exception e) { + likeFailureCount.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + for (int i = 0; i < stockRequestCount; i++) { + executorService.execute(() -> { + ready.countDown(); + try { + start.await(); + stockAction.run(); + stockSuccessCount.incrementAndGet(); + } catch (CoreException e) { + if (e.getErrorType() == ErrorType.BAD_REQUEST) { + stockOutOfStockCount.incrementAndGet(); + } else if (e.getErrorType() == ErrorType.CONFLICT) { + stockConflictCount.incrementAndGet(); + } else { + stockFailureCount.incrementAndGet(); + } + } catch (Exception e) { + stockFailureCount.incrementAndGet(); + } finally { + done.countDown(); + } + }); + } + + ready.await(3, TimeUnit.SECONDS); + start.countDown(); + done.await(15, TimeUnit.SECONDS); + + long elapsedNanos = System.nanoTime() - startedAtNanos; + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(elapsedNanos); + double likeThroughput = elapsedNanos == 0L ? 0D : (likeSuccessCount.get() * 1_000_000_000D) / elapsedNanos; + double stockThroughput = elapsedNanos == 0L ? 0D : (stockSuccessCount.get() * 1_000_000_000D) / elapsedNanos; + executorService.shutdownNow(); + + return new MixedLikeAndStockMetrics( + productId, + likeRequestCount, + stockRequestCount, + likeSuccessCount.get(), + likeFailureCount.get(), + stockSuccessCount.get(), + stockOutOfStockCount.get(), + stockConflictCount.get(), + stockFailureCount.get(), + elapsedMillis, + likeThroughput, + stockThroughput + ); + } + + private void printMetrics(String strategy, StockConcurrencyMetrics metrics) { + System.out.printf( + "[STOCK_CONCURRENCY_METRICS] strategy=%s requests=%d success=%d outOfStock=%d conflict=%d failure=%d elapsedMs=%d throughput=%.2f finalStockEstimate=%d%n", + strategy, + metrics.requestCount(), + metrics.successCount(), + metrics.outOfStockCount(), + metrics.conflictCount(), + metrics.failureCount(), + metrics.elapsedMillis(), + metrics.successThroughputPerSec(), + 10 - metrics.successCount() + ); + } + + private void printMixedMetrics(String strategy, MixedLikeAndStockMetrics metrics) { + System.out.printf( + "[LIKE_STOCK_MIXED_METRICS] strategy=%s likeRequests=%d stockRequests=%d likeSuccess=%d likeFailure=%d stockSuccess=%d stockOutOfStock=%d stockConflict=%d stockFailure=%d elapsedMs=%d likeThroughput=%.2f stockThroughput=%.2f finalStockEstimate=%d%n", + strategy, + metrics.likeRequestCount(), + metrics.stockRequestCount(), + metrics.likeSuccessCount(), + metrics.likeFailureCount(), + metrics.stockSuccessCount(), + metrics.stockOutOfStockCount(), + metrics.stockConflictCount(), + metrics.stockFailureCount(), + metrics.elapsedMillis(), + metrics.likeSuccessThroughputPerSec(), + metrics.stockSuccessThroughputPerSec(), + 10 - metrics.stockSuccessCount() + ); + } + + record StockConcurrencyMetrics( + UUID productId, + int requestCount, + int successCount, + int outOfStockCount, + int conflictCount, + int failureCount, + long elapsedMillis, + double successThroughputPerSec + ) { + } + + record MixedLikeAndStockMetrics( + UUID productId, + int likeRequestCount, + int stockRequestCount, + int likeSuccessCount, + int likeFailureCount, + int stockSuccessCount, + int stockOutOfStockCount, + int stockConflictCount, + int stockFailureCount, + long elapsedMillis, + double likeSuccessThroughputPerSec, + double stockSuccessThroughputPerSec + ) { + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainTest.java new file mode 100644 index 000000000..49ea0f9f0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@DisplayName("Coupon 도메인 테스트") +class CouponDomainTest { + + @Test + @DisplayName("Coupon 도메인 클래스가 존재해야 한다") + void couponClassShouldExist() { + try { + Class.forName("com.loopers.domain.coupon.Coupon"); + } catch (ClassNotFoundException e) { + fail("RED: Coupon 도메인 클래스(com.loopers.domain.coupon.Coupon)가 아직 없습니다."); + } + } + + @Nested + @DisplayName("할인 계산") + class Discount { + @Test + @DisplayName("정액 쿠폰은 고정 금액을 할인한다") + void fixedDiscount() { + int discount = invokeCalculateDiscount("FIXED", 3_000, 10_000, LocalDateTime.now().plusDays(1), 20_000); + assertThat(discount).isEqualTo(3_000); + } + + @Test + @DisplayName("정률 쿠폰은 퍼센트로 할인한다") + void rateDiscount() { + int discount = invokeCalculateDiscount("RATE", 10, 10_000, LocalDateTime.now().plusDays(1), 30_000); + assertThat(discount).isEqualTo(3_000); + } + + @Test + @DisplayName("최소 주문 금액 미달이면 할인 0") + void belowMinOrderAmount() { + int discount = invokeCalculateDiscount("FIXED", 3_000, 10_000, LocalDateTime.now().plusDays(1), 9_000); + assertThat(discount).isZero(); + } + } + + @Nested + @DisplayName("사용 가능성") + class Usable { + @Test + @DisplayName("만료 후 사용 불가") + void expired() { + boolean usable = invokeIsUsableAt("FIXED", 1_000, 0, LocalDateTime.now().minusSeconds(1), LocalDateTime.now()); + assertThat(usable).isFalse(); + } + + @Test + @DisplayName("만료 전 사용 가능") + void beforeExpired() { + boolean usable = invokeIsUsableAt("FIXED", 1_000, 0, LocalDateTime.now().plusSeconds(1), LocalDateTime.now()); + assertThat(usable).isTrue(); + } + } + + private static Object createCoupon(String typeName, int value, int minOrderAmount, LocalDateTime expiredAt) { + try { + Class couponClass = Class.forName("com.loopers.domain.coupon.Coupon"); + Class couponTypeClass = Class.forName("com.loopers.domain.coupon.CouponType"); + Object couponType = Enum.valueOf((Class) couponTypeClass.asSubclass(Enum.class), typeName); + + Constructor ctor = couponClass.getDeclaredConstructor( + String.class, + couponTypeClass, + int.class, + int.class, + LocalDateTime.class + ); + return ctor.newInstance("테스트 쿠폰", couponType, value, minOrderAmount, expiredAt); + } catch (Exception e) { + fail("RED: Coupon 생성 규약 구현 필요. 원인=" + e.getClass().getSimpleName()); + return null; + } + } + + private static int invokeCalculateDiscount(String typeName, int value, int minOrderAmount, LocalDateTime expiredAt, int orderAmount) { + try { + Object coupon = createCoupon(typeName, value, minOrderAmount, expiredAt); + Method m = coupon.getClass().getDeclaredMethod("calculateDiscount", int.class); + return (int) m.invoke(coupon, orderAmount); + } catch (Exception e) { + fail("RED: Coupon.calculateDiscount(int) 구현 필요. 원인=" + e.getClass().getSimpleName()); + return -1; + } + } + + private static boolean invokeIsUsableAt(String typeName, int value, int minOrderAmount, LocalDateTime expiredAt, LocalDateTime now) { + try { + Object coupon = createCoupon(typeName, value, minOrderAmount, expiredAt); + Method m = coupon.getClass().getDeclaredMethod("isUsableAt", LocalDateTime.class); + return (boolean) m.invoke(coupon, now); + } catch (Exception e) { + fail("RED: Coupon.isUsableAt(LocalDateTime) 구현 필요. 원인=" + e.getClass().getSimpleName()); + return false; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponDomainTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponDomainTest.java new file mode 100644 index 000000000..65e2c2bd7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/IssuedCouponDomainTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@DisplayName("IssuedCoupon 도메인 테스트") +class IssuedCouponDomainTest { + + @Test + @DisplayName("IssuedCoupon 도메인 클래스가 존재해야 한다") + void issuedCouponClassShouldExist() { + try { + Class.forName("com.loopers.domain.coupon.IssuedCoupon"); + } catch (ClassNotFoundException e) { + fail("RED: IssuedCoupon 도메인 클래스(com.loopers.domain.coupon.IssuedCoupon)가 아직 없습니다."); + } + } + + @Test + @DisplayName("markUsed 호출 시 상태가 USED로 변경되어야 한다") + void markUsedTransition() { + Object issuedCoupon = createIssuedCoupon("AVAILABLE", LocalDateTime.now().plusDays(1)); + try { + Method markUsed = issuedCoupon.getClass().getDeclaredMethod("markUsed"); + Object updated = markUsed.invoke(issuedCoupon); + + Method status = updated.getClass().getDeclaredMethod("status"); + Object statusValue = status.invoke(updated); + assertThat(String.valueOf(statusValue)).isEqualTo("USED"); + } catch (NoSuchMethodException e) { + fail("RED: IssuedCoupon.markUsed()/status() 구현 필요"); + } catch (Exception e) { + fail("RED: IssuedCoupon 상태 전이 구현 필요. 원인=" + e.getClass().getSimpleName()); + } + } + + @Test + @DisplayName("만료된 IssuedCoupon은 validateUsable에서 거부되어야 한다") + void expiredIssuedCouponShouldBeRejected() { + Object issuedCoupon = createIssuedCoupon("AVAILABLE", LocalDateTime.now().minusSeconds(1)); + try { + Method validateUsable = issuedCoupon.getClass().getDeclaredMethod("validateUsable", LocalDateTime.class); + validateUsable.invoke(issuedCoupon, LocalDateTime.now()); + fail("RED: 만료 쿠폰 사용 거부 로직이 필요합니다."); + } catch (NoSuchMethodException e) { + fail("RED: IssuedCoupon.validateUsable(LocalDateTime) 구현 필요"); + } catch (Exception ignored) { + assertThat(true).isTrue(); + } + } + + private static Object createIssuedCoupon(String statusName, LocalDateTime expiredAt) { + try { + Class issuedCouponClass = Class.forName("com.loopers.domain.coupon.IssuedCoupon"); + Class statusClass = Class.forName("com.loopers.domain.coupon.CouponStatus"); + Object status = Enum.valueOf((Class) statusClass.asSubclass(Enum.class), statusName); + + Constructor ctor = issuedCouponClass.getDeclaredConstructor( + String.class, + UUID.class, + statusClass, + LocalDateTime.class, + LocalDateTime.class, + LocalDateTime.class + ); + + return ctor.newInstance( + "member-1", + UUID.fromString("00000000-0000-0000-0000-000000000001"), + status, + LocalDateTime.now(), + expiredAt, + null + ); + } catch (Exception e) { + fail("RED: IssuedCoupon 생성 규약 구현 필요. 원인=" + e.getClass().getSimpleName()); + return null; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java index 623da84c3..00467600d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -8,6 +8,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.UUID; class OrderItemTest { @@ -18,9 +19,10 @@ class Create { @Test @DisplayName("유효한 값으로 OrderItem 생성 성공") void createSuccess() { - OrderItem item = new OrderItem(1L, 2, "강아지 사료", 10000, "퍼피박스"); + UUID productId = UUID.randomUUID(); + OrderItem item = new OrderItem(productId, 2, "강아지 사료", 10000, "퍼피박스"); - assertThat(item.productId()).isEqualTo(1L); + assertThat(item.productId()).isEqualTo(productId); assertThat(item.quantity()).isEqualTo(2); assertThat(item.snapshotProductName()).isEqualTo("강아지 사료"); assertThat(item.snapshotPrice()).isEqualTo(10000); @@ -30,7 +32,7 @@ void createSuccess() { @Test @DisplayName("수량이 0이면 예외가 발생한다") void zeroQuantityFails() { - assertThatThrownBy(() -> new OrderItem(1L, 0, "강아지 사료", 10000, "퍼피박스")) + assertThatThrownBy(() -> new OrderItem(UUID.randomUUID(), 0, "강아지 사료", 10000, "퍼피박스")) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -38,7 +40,7 @@ void zeroQuantityFails() { @Test @DisplayName("수량이 음수이면 예외가 발생한다") void negativeQuantityFails() { - assertThatThrownBy(() -> new OrderItem(1L, -1, "강아지 사료", 10000, "퍼피박스")) + assertThatThrownBy(() -> new OrderItem(UUID.randomUUID(), -1, "강아지 사료", 10000, "퍼피박스")) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -46,7 +48,7 @@ void negativeQuantityFails() { @Test @DisplayName("상품명 스냅샷이 blank이면 예외가 발생한다") void blankProductNameFails() { - assertThatThrownBy(() -> new OrderItem(1L, 1, " ", 10000, "퍼피박스")) + assertThatThrownBy(() -> new OrderItem(UUID.randomUUID(), 1, " ", 10000, "퍼피박스")) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -54,7 +56,7 @@ void blankProductNameFails() { @Test @DisplayName("브랜드명 스냅샷이 blank이면 예외가 발생한다") void blankBrandNameFails() { - assertThatThrownBy(() -> new OrderItem(1L, 1, "강아지 사료", 10000, " ")) + assertThatThrownBy(() -> new OrderItem(UUID.randomUUID(), 1, "강아지 사료", 10000, " ")) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -62,7 +64,7 @@ void blankBrandNameFails() { @Test @DisplayName("가격 스냅샷이 음수이면 예외가 발생한다") void negativePriceFails() { - assertThatThrownBy(() -> new OrderItem(1L, 1, "강아지 사료", -1, "퍼피박스")) + assertThatThrownBy(() -> new OrderItem(UUID.randomUUID(), 1, "강아지 사료", -1, "퍼피박스")) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -75,7 +77,7 @@ class TotalPrice { @Test @DisplayName("수량 * 단가를 반환한다") void calculateTotalPrice() { - OrderItem item = new OrderItem(1L, 3, "강아지 사료", 5000, "퍼피박스"); + OrderItem item = new OrderItem(UUID.randomUUID(), 3, "강아지 사료", 5000, "퍼피박스"); assertThat(item.totalPrice()).isEqualTo(15000); } 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 3c1e399e3..211e03af7 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 @@ -7,14 +7,19 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class OrderTest { + private static final String MEMBER_ID = "member-1"; + private static final String OTHER_MEMBER_ID = "member-2"; + private static final UUID PRODUCT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final OrderItem SAMPLE_ITEM = new OrderItem( - 1L, 2, "강아지 사료", 10000, "퍼피박스" + PRODUCT_ID, 2, "강아지 사료", 10000, "퍼피박스" ); @Nested @@ -24,10 +29,10 @@ class Create { @Test @DisplayName("유효한 항목으로 주문 생성 시 ORDERED 상태로 생성된다") void createOrderSuccess() { - Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order order = new Order(MEMBER_ID, "ORDER-001", List.of(SAMPLE_ITEM)); assertThat(order.status()).isEqualTo(OrderStatus.ORDERED); - assertThat(order.userId()).isEqualTo(1L); + assertThat(order.memberId()).isEqualTo(MEMBER_ID); assertThat(order.items()).hasSize(1); assertThat(order.totalAmount()).isEqualTo(20000); } @@ -43,7 +48,7 @@ void nullUserIdFails() { @Test @DisplayName("빈 items로 주문 생성 시 예외가 발생한다") void emptyItemsFails() { - assertThatThrownBy(() -> new Order(1L, "ORDER-001", List.of())) + assertThatThrownBy(() -> new Order(MEMBER_ID, "ORDER-001", List.of())) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -56,7 +61,7 @@ class Cancel { @Test @DisplayName("ORDERED 상태 주문을 취소하면 CANCELLED 상태가 된다") void cancelOrderedOrder() { - Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order order = new Order(MEMBER_ID, "ORDER-001", List.of(SAMPLE_ITEM)); Order cancelled = order.cancel(); @@ -66,7 +71,7 @@ void cancelOrderedOrder() { @Test @DisplayName("이미 취소된 주문을 재취소하면 409 예외가 발생한다") void cancelAlreadyCancelledOrderFails() { - Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order order = new Order(MEMBER_ID, "ORDER-001", List.of(SAMPLE_ITEM)); Order cancelled = order.cancel(); assertThatThrownBy(cancelled::cancel) @@ -80,19 +85,19 @@ void cancelAlreadyCancelledOrderFails() { class Ownership { @Test - @DisplayName("주문한 userId와 일치하면 true를 반환한다") + @DisplayName("주문한 memberId와 일치하면 true를 반환한다") void isOwnerReturnsTrue() { - Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order order = new Order(MEMBER_ID, "ORDER-001", List.of(SAMPLE_ITEM)); - assertThat(order.isOwner(1L)).isTrue(); + assertThat(order.isOwner(MEMBER_ID)).isTrue(); } @Test - @DisplayName("주문한 userId와 다르면 false를 반환한다") + @DisplayName("주문한 memberId와 다르면 false를 반환한다") void isOwnerReturnsFalse() { - Order order = new Order(1L, "ORDER-001", List.of(SAMPLE_ITEM)); + Order order = new Order(MEMBER_ID, "ORDER-001", List.of(SAMPLE_ITEM)); - assertThat(order.isOwner(2L)).isFalse(); + assertThat(order.isOwner(OTHER_MEMBER_ID)).isFalse(); } } } 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 1e1370cf4..42362d7f8 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 @@ -8,13 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.UUID; class ProductTest { @Test @DisplayName("좋아요 감소 시 0 미만으로 내려가지 않는다") void decreaseLikeCountNotBelowZero() { - Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 10, "desc", UUID.randomUUID(), UUID.randomUUID()); Product decreased = product.decreaseLikeCount(); @@ -24,7 +25,7 @@ void decreaseLikeCountNotBelowZero() { @Test @DisplayName("가격이 음수면 예외가 발생한다") void negativePriceFails() { - assertThatThrownBy(() -> new Product("사료", -1, 10, "desc", 1L, 1L)) + assertThatThrownBy(() -> new Product("사료", -1, 10, "desc", UUID.randomUUID(), UUID.randomUUID())) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -32,7 +33,7 @@ void negativePriceFails() { @Test @DisplayName("카테고리 ID가 없으면 예외가 발생한다") void categoryIdMissingFails() { - assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", null, 1L)) + assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", null, UUID.randomUUID())) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -40,7 +41,7 @@ void categoryIdMissingFails() { @Test @DisplayName("브랜드 ID가 없으면 예외가 발생한다") void brandIdMissingFails() { - assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", 1L, null)) + assertThatThrownBy(() -> new Product("사료", 1000, 10, "desc", UUID.randomUUID(), null)) .isInstanceOf(CoreException.class) .satisfies(ex -> assertThat(((CoreException) ex).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } @@ -52,7 +53,7 @@ class DecreaseStock { @Test @DisplayName("정상 수량으로 재고 차감 시 재고가 줄어든다") void decreaseStockSuccess() { - Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 10, "desc", UUID.randomUUID(), UUID.randomUUID()); Product updated = product.decreaseStock(3); @@ -62,7 +63,7 @@ void decreaseStockSuccess() { @Test @DisplayName("재고보다 많은 수량으로 차감 시 예외가 발생한다") void decreaseStockBelowZeroFails() { - Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 5, "desc", UUID.randomUUID(), UUID.randomUUID()); assertThatThrownBy(() -> product.decreaseStock(6)) .isInstanceOf(CoreException.class) @@ -72,7 +73,7 @@ void decreaseStockBelowZeroFails() { @Test @DisplayName("수량이 0이면 예외가 발생한다") void zeroQuantityFails() { - Product product = new Product("사료", 1000, 10, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 10, "desc", UUID.randomUUID(), UUID.randomUUID()); assertThatThrownBy(() -> product.decreaseStock(0)) .isInstanceOf(CoreException.class) @@ -87,7 +88,7 @@ class IncreaseStock { @Test @DisplayName("정상 수량으로 재고 복원 시 재고가 늘어난다") void increaseStockSuccess() { - Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 5, "desc", UUID.randomUUID(), UUID.randomUUID()); Product updated = product.increaseStock(3); @@ -97,7 +98,7 @@ void increaseStockSuccess() { @Test @DisplayName("수량이 0이면 예외가 발생한다") void zeroQuantityFails() { - Product product = new Product("사료", 1000, 5, "desc", 1L, 1L); + Product product = new Product("사료", 1000, 5, "desc", UUID.randomUUID(), UUID.randomUUID()); assertThatThrownBy(() -> product.increaseStock(0)) .isInstanceOf(CoreException.class) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java index 98379de5a..48ebfa2e9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminApiControllerTest.java @@ -7,7 +7,14 @@ import com.loopers.domain.brand.vo.BrandName; import com.loopers.domain.category.Category; import com.loopers.domain.category.CategoryRepository; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.infrastructure.coupon.CouponEntity; +import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.domain.coupon.CouponType; import com.loopers.interfaces.api.brand.BrandDto; +import com.loopers.interfaces.api.coupon.CouponAdminDto; import com.loopers.interfaces.api.product.ProductDto; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; @@ -18,21 +25,25 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDateTime; +import java.util.UUID; + import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class AdminApiControllerTest { @@ -54,6 +65,12 @@ class AdminApiControllerTest { @Autowired private CategoryRepository categoryRepository; + @Autowired + private CouponJpaRepository couponJpaRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -118,13 +135,13 @@ void createAndGetBrandSuccess() throws Exception { .getContentAsString(); JsonNode json = objectMapper.readTree(body); - long brandId = json.path("data").path("id").asLong(); + UUID brandId = UUID.fromString(json.path("data").path("id").asText()); mockMvc.perform(get("/api-admin/v1/brands/{brandId}", brandId) .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) .andExpect(status().isOk()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.id").value(brandId)); + .andExpect(jsonPath("$.data.id").value(brandId.toString())); } } @@ -135,8 +152,8 @@ class AdminProductApi { @Test @DisplayName("상품 생성/목록/상세/수정/삭제 성공") void productCrudSuccess() throws Exception { - Long categoryId = categoryRepository.save(new Category("카테고리A")).id(); - Long brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); + UUID categoryId = categoryRepository.save(new Category("카테고리A")).id(); + UUID brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); ProductDto.CreateProductRequest createRequest = new ProductDto.CreateProductRequest( "상품A", @@ -157,7 +174,7 @@ void productCrudSuccess() throws Exception { .getResponse() .getContentAsString(); - long productId = objectMapper.readTree(createBody).path("data").path("id").asLong(); + UUID productId = UUID.fromString(objectMapper.readTree(createBody).path("data").path("id").asText()); mockMvc.perform(get("/api-admin/v1/products") .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) @@ -171,7 +188,7 @@ void productCrudSuccess() throws Exception { .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) .andExpect(status().isOk()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.id").value(productId)); + .andExpect(jsonPath("$.data.id").value(productId.toString())); ProductDto.UpdateProductRequest updateRequest = new ProductDto.UpdateProductRequest( "상품A-수정", @@ -205,8 +222,8 @@ void productCrudSuccess() throws Exception { @Test @DisplayName("상품 수정 시 brandId를 보내면 400") void updateProductFailsWhenBrandIdProvided() throws Exception { - Long categoryId = categoryRepository.save(new Category("카테고리A")).id(); - Long brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); + UUID categoryId = categoryRepository.save(new Category("카테고리A")).id(); + UUID brandId = brandRepository.save(new Brand(new BrandName("브랜드A"), "desc", "img")).id(); ProductDto.CreateProductRequest createRequest = new ProductDto.CreateProductRequest( "상품A", @@ -226,7 +243,7 @@ void updateProductFailsWhenBrandIdProvided() throws Exception { .getResponse() .getContentAsString(); - long productId = objectMapper.readTree(createBody).path("data").path("id").asLong(); + UUID productId = UUID.fromString(objectMapper.readTree(createBody).path("data").path("id").asText()); ProductDto.UpdateProductRequest updateRequest = new ProductDto.UpdateProductRequest( "상품A-수정", @@ -244,4 +261,101 @@ void updateProductFailsWhenBrandIdProvided() throws Exception { .andExpect(status().isBadRequest()); } } + + @Nested + @DisplayName("Admin Coupon API") + class AdminCouponApi { + + @Test + @DisplayName("쿠폰 템플릿 생성/목록/상세/수정/삭제 성공") + void couponCrudSuccess() throws Exception { + CouponAdminDto.CreateCouponRequest createRequest = new CouponAdminDto.CreateCouponRequest( + "신규가입 10% 할인", + CouponType.RATE, + 10, + 10000, + LocalDateTime.now().plusDays(30) + ); + + String createBody = mockMvc.perform(post("/api-admin/v1/coupons") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andReturn() + .getResponse() + .getContentAsString(); + + UUID couponId = UUID.fromString(objectMapper.readTree(createBody).path("data").path("id").asText()); + + mockMvc.perform(get("/api-admin/v1/coupons") + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(1)); + + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}", couponId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(couponId.toString())); + + CouponAdminDto.UpdateCouponRequest updateRequest = new CouponAdminDto.UpdateCouponRequest( + "수정 쿠폰", + CouponType.FIXED, + 3000, + 15000, + LocalDateTime.now().plusDays(40) + ); + + mockMvc.perform(put("/api-admin/v1/coupons/{couponId}", couponId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.name").value("수정 쿠폰")) + .andExpect(jsonPath("$.data.type").value("FIXED")); + + mockMvc.perform(delete("/api-admin/v1/coupons/{couponId}", couponId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")); + + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}", couponId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("특정 쿠폰 발급 내역 조회 성공") + void listCouponIssuesSuccess() throws Exception { + CouponEntity couponEntity = couponJpaRepository.saveAndFlush( + new CouponEntity("이슈 조회 쿠폰", CouponType.FIXED, 1000, 0, LocalDateTime.now().plusDays(10)) + ); + UUID couponId = couponEntity.getId(); + + issuedCouponRepository.save(new IssuedCoupon( + "memberOne", + couponId, + CouponStatus.AVAILABLE, + LocalDateTime.now(), + LocalDateTime.now().plusDays(10), + null + )); + + mockMvc.perform(get("/api-admin/v1/coupons/{couponId}/issues", couponId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.items[0].couponId").value(couponId.toString())) + .andExpect(jsonPath("$.data.items[0].memberId").value("memberOne")); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java index 5e2121cdd..0cc03b972 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminCategoryApiE2ETest.java @@ -13,7 +13,7 @@ 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.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -23,9 +23,10 @@ import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; +import java.util.UUID; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class AdminCategoryApiE2ETest { @@ -102,7 +103,7 @@ class Detail { @Test @DisplayName("존재하는 카테고리를 상세 조회한다") void getCategory_whenExists_returnsOk() { - Long categoryId = createCategory("리빙"); + UUID categoryId = createCategory("리빙"); ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_CATEGORIES + "/" + categoryId, @@ -140,7 +141,7 @@ private HttpHeaders adminHeaders() { return headers; } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminOrderControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminOrderControllerTest.java new file mode 100644 index 000000000..e0016213e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/admin/AdminOrderControllerTest.java @@ -0,0 +1,81 @@ +package com.loopers.interfaces.api.admin; + +import com.loopers.application.order.OrderUseCase; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class AdminOrderControllerTest { + + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private OrderUseCase orderUseCase; + + @Autowired + private OrderRepository orderRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("관리자 주문 취소는 OrderUseCase.cancel을 호출한다") + void cancelOrder_callsOrderUseCase() throws Exception { + UUID productId = UUID.fromString("00000000-0000-0000-0000-000000000999"); + + Order ordered = new Order( + "member-1", + "ORDER-1", + List.of(new OrderItem(productId, 1, "사료", 10000, "브랜드A")), + null + ); + UUID orderId = orderRepository.save(ordered).id(); + + mockMvc.perform(patch("/api-admin/v1/orders/{orderId}/cancel", orderId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(orderId.toString())) + .andExpect(jsonPath("$.data.status").value("CANCELLED")); + } + + @Test + @DisplayName("관리자 주문 취소는 LDAP 헤더가 없으면 401") + void cancelOrder_withoutLdapHeader_returnsUnauthorized() throws Exception { + UUID orderId = UUID.fromString("00000000-0000-0000-0000-000000000123"); + + mockMvc.perform(patch("/api-admin/v1/orders/{orderId}/cancel", orderId)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponApiE2ETest.java new file mode 100644 index 000000000..e4573c47d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponApiE2ETest.java @@ -0,0 +1,105 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.member.MemberDto; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +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 org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Coupon API E2E 테스트") +class CouponApiE2ETest { + + private static final String TEST_LOGIN_ID = "coupone2euser"; + private static final String TEST_PASSWORD = "Test1234!@"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + MemberDto.RegisterRequest registerRequest = new MemberDto.RegisterRequest( + TEST_LOGIN_ID, + TEST_PASSWORD, + "쿠폰이", + "19900101", + "coupon.e2e@test.com", + "010-9999-8888" + ); + + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, TEST_LOGIN_ID); + headers.set(HEADER_LOGIN_PW, TEST_PASSWORD); + return headers; + } + + @Nested + @DisplayName("쿠폰 발급/조회") + class CouponIssueAndList { + @Test + @DisplayName("존재하지 않는 쿠폰 발급 요청은 404") + void issueInvalidCouponId() { + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/coupons/00000000-0000-0000-0000-000000000999/issue", + HttpMethod.POST, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("내 쿠폰 목록 조회는 200") + void listMyCoupons() { + ResponseEntity>> response = testRestTemplate.exchange( + "/api/v1/users/me/coupons", + HttpMethod.GET, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponControllerTest.java new file mode 100644 index 000000000..b68f2bb52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponControllerTest.java @@ -0,0 +1,106 @@ +package com.loopers.interfaces.api.coupon; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ImportTestcontainers(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +@DisplayName("Coupon API Controller 통합 테스트") +class CouponControllerTest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final String TEST_LOGIN_ID = "coupontestuser"; + private static final String TEST_PASSWORD = "Test1234!@"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() throws Exception { + Map registerRequest = Map.of( + "loginId", TEST_LOGIN_ID, + "password", TEST_PASSWORD, + "name", "쿠폰이", + "birthDate", "19900101", + "email", "coupon@test.com", + "phone", "010-1234-5678" + ); + + mockMvc.perform(post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/coupons/{couponId}/issue") + class IssueCoupon { + @Test + @DisplayName("인증 없이 발급 요청하면 401") + void issueWithoutAuth() throws Exception { + mockMvc.perform(post("/api/v1/coupons/00000000-0000-0000-0000-000000000001/issue")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("존재하지 않는 couponId로 발급 요청하면 404") + void issueInvalidCouponId() throws Exception { + mockMvc.perform(post("/api/v1/coupons/00000000-0000-0000-0000-000000000999/issue") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/users/me/coupons") + class ListMyCoupons { + @Test + @DisplayName("인증 없이 조회하면 401") + void listWithoutAuth() throws Exception { + mockMvc.perform(get("/api/v1/users/me/coupons")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("인증 시 조회 성공(200)") + void listWithAuth() throws Exception { + mockMvc.perform(get("/api/v1/users/me/coupons") + .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) + .header(HEADER_LOGIN_PW, TEST_PASSWORD)) + .andExpect(status().isOk()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java index 2a830193b..20529c68d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberApiE2ETest.java @@ -10,7 +10,7 @@ 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.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.test.context.ActiveProfiles; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class MemberApiE2ETest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java index 1bcb6066a..c6eb70f6c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberControllerTest.java @@ -10,7 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -23,7 +23,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class MemberControllerTest { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index 274e378f8..e95d7e1b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -15,7 +15,7 @@ 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.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -27,9 +27,10 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import java.util.UUID; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class OrderApiE2ETest { @@ -45,8 +46,8 @@ class OrderApiE2ETest { private final TestRestTemplate testRestTemplate; private final DatabaseCleanUp databaseCleanUp; private final CategoryRepository categoryRepository; - private Long brandId; - private Long categoryId; + private UUID brandId; + private UUID categoryId; @Autowired public OrderApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp, CategoryRepository categoryRepository) { @@ -84,7 +85,7 @@ private HttpHeaders authHeaders() { return headers; } - private Long createProduct(String name, int price, int stock) { + private UUID createProduct(String name, int price, int stock) { ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( name, price, stock, "설명", categoryId, brandId ); @@ -100,7 +101,7 @@ private Long createProduct(String name, int price, int stock) { return response.getBody().data().id(); } - private Long createBrand(String name) { + private UUID createBrand(String name) { var request = new com.loopers.interfaces.api.brand.BrandDto.CreateBrandRequest( name, "테스트 브랜드", @@ -125,7 +126,7 @@ private Long createBrand(String name) { return response.getBody().data().id(); } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } @@ -137,7 +138,7 @@ class OrderCrudScenario { @DisplayName("주문 생성 → 상세 조회 → 취소 → 재취소 실패 시나리오") void fullOrderFlow() { // 상품 생성 - Long productId = createProduct("강아지 사료", 10000, 50); + UUID productId = createProduct("강아지 사료", 10000, 50); // 주문 생성 OrderDto.CreateOrderRequest createRequest = new OrderDto.CreateOrderRequest( @@ -152,7 +153,7 @@ void fullOrderFlow() { ); assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); - Long orderId = created.getBody().data().id(); + UUID orderId = created.getBody().data().id(); assertThat(created.getBody().data().status()).isEqualTo("ORDERED"); assertThat(created.getBody().data().totalAmount()).isEqualTo(20000); @@ -193,7 +194,7 @@ void fullOrderFlow() { @Test @DisplayName("재고 부족 시 주문이 실패하고 재고가 차감되지 않는다") void insufficientStockFails() { - Long productId = createProduct("한정판 사료", 50000, 2); + UUID productId = createProduct("한정판 사료", 50000, 2); OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( List.of(new OrderDto.OrderItemRequest(productId, 5)) @@ -212,7 +213,7 @@ void insufficientStockFails() { @Test @DisplayName("주문 목록 조회 - 기간 내 주문만 반환된다") void listOrdersWithDateFilter() { - Long productId = createProduct("사료", 5000, 100); + UUID productId = createProduct("사료", 5000, 100); OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( List.of(new OrderDto.OrderItemRequest(productId, 1)) @@ -239,7 +240,7 @@ void listOrdersWithDateFilter() { @Test @DisplayName("취소 후 재고가 복원된다") void stockRestoredAfterCancel() { - Long productId = createProduct("귀한 사료", 20000, 3); + UUID productId = createProduct("귀한 사료", 20000, 3); // 3개 주문 (재고 0이 됨) OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( @@ -253,7 +254,7 @@ void stockRestoredAfterCancel() { new ParameterizedTypeReference<>() {} ); - Long orderId = created.getBody().data().id(); + UUID orderId = created.getBody().data().id(); // 취소 (재고 복원) testRestTemplate.exchange( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java index c9bb97084..8adb7b59b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderControllerTest.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -23,10 +23,11 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class OrderControllerTest { @@ -68,7 +69,7 @@ class CreateOrder { @DisplayName("인증 없이 주문하면 401을 반환한다") void createOrderWithoutAuthFails() throws Exception { OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( - List.of(new OrderDto.OrderItemRequest(1L, 1)) + List.of(new OrderDto.OrderItemRequest(UUID.randomUUID(), 1)) ); mockMvc.perform(post("/api/v1/orders") @@ -94,7 +95,7 @@ void createOrderWithEmptyItemsFails() throws Exception { @DisplayName("존재하지 않는 상품 주문 시 404를 반환한다") void createOrderWithNonExistentProductFails() throws Exception { OrderDto.CreateOrderRequest request = new OrderDto.CreateOrderRequest( - List.of(new OrderDto.OrderItemRequest(99999L, 1)) + List.of(new OrderDto.OrderItemRequest(UUID.randomUUID(), 1)) ); mockMvc.perform(post("/api/v1/orders") @@ -113,7 +114,7 @@ class CancelOrder { @Test @DisplayName("존재하지 않는 주문 취소 시 404를 반환한다") void cancelNonExistentOrderFails() throws Exception { - mockMvc.perform(patch("/api/v1/orders/99999/cancel") + mockMvc.perform(patch("/api/v1/orders/{orderId}/cancel", UUID.randomUUID()) .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) .header(HEADER_LOGIN_PW, TEST_PASSWORD)) .andExpect(status().isNotFound()); @@ -122,7 +123,7 @@ void cancelNonExistentOrderFails() throws Exception { @Test @DisplayName("인증 없이 취소하면 401을 반환한다") void cancelWithoutAuthFails() throws Exception { - mockMvc.perform(patch("/api/v1/orders/1/cancel")) + mockMvc.perform(patch("/api/v1/orders/{orderId}/cancel", UUID.randomUUID())) .andExpect(status().isUnauthorized()); } } @@ -163,7 +164,7 @@ class GetOrderDetail { @Test @DisplayName("존재하지 않는 주문 조회 시 404를 반환한다") void getNonExistentOrderFails() throws Exception { - mockMvc.perform(get("/api/v1/orders/99999") + mockMvc.perform(get("/api/v1/orders/{orderId}", UUID.randomUUID()) .header(HEADER_LOGIN_ID, TEST_LOGIN_ID) .header(HEADER_LOGIN_PW, TEST_PASSWORD)) .andExpect(status().isNotFound()); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java index 3292e8c25..0f68cfedc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java @@ -21,7 +21,7 @@ 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.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -29,11 +29,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import java.util.UUID; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") @DisplayName("Like API E2E Tests") class LikeApiE2ETest { @@ -54,8 +56,8 @@ class LikeApiE2ETest { private final ProductJpaRepository productJpaRepository; private final LikeJpaRepository likeJpaRepository; - private Long brandId; - private Long categoryId; + private UUID brandId; + private UUID categoryId; @Autowired public LikeApiE2ETest( @@ -93,7 +95,7 @@ class Register { @DisplayName("인증된 사용자가 활성 상품에 좋아요를 누르면 201을 반환한다") void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() { registerMember("likeApiMember", "Password1!", "홍길동", "19900101", "api-like@example.com", "010-1234-5678"); - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); HttpHeaders headers = headers("likeApiMember", "Password1!"); int beforeLikeCount = getProductLikeCount(productId); @@ -114,7 +116,7 @@ void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() { @DisplayName("이미 좋아요한 상품을 다시 누르면 409을 반환한다") void registerLike_whenAlreadyLikedProduct_returnsConflict() { registerMember("likeApiConfMem", "Password1!", "홍길동", "19900101", "api-like-conflict@example.com", "010-2345-6789"); - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); HttpHeaders headers = headers("likeApiConfMem", "Password1!"); testRestTemplate.exchange( @@ -140,7 +142,7 @@ void registerLike_whenAlreadyLikedProduct_returnsConflict() { @DisplayName("삭제된 상품에 대해 좋아요 요청을 보내면 400을 반환한다") void registerLike_whenDeletedProduct_returnsBadRequest() { registerMember("likeApiDelMem", "Password1!", "홍길동", "19900101", "api-like-deleted@example.com", "010-3456-7890"); - Long productId = createProduct("삭제될 상품", 10_000, 50); + UUID productId = createProduct("삭제될 상품", 10_000, 50); deleteProductAsAdmin(productId); HttpHeaders headers = headers("likeApiDelMem", "Password1!"); @@ -163,7 +165,7 @@ void registerLike_whenProductNotFound_returnsNotFound() { HttpHeaders headers = headers("likeApiMissMem", "Password1!"); ResponseEntity> response = testRestTemplate.exchange( - productLikesUrl(0L), + productLikesUrl(UUID.randomUUID()), HttpMethod.POST, new HttpEntity<>(headers), new ParameterizedTypeReference>() { @@ -176,7 +178,7 @@ void registerLike_whenProductNotFound_returnsNotFound() { @Test @DisplayName("인증 정보가 없으면 401을 반환한다") void registerLike_whenNoAuthentication_returnsUnauthorized() { - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); ResponseEntity> response = testRestTemplate.exchange( productLikesUrl(productId), @@ -198,7 +200,7 @@ class Cancel { @DisplayName("좋아요 상태에서 삭제 요청하면 200을 반환한다") void cancelLike_whenLikedProduct_returnsOk() { registerMember("likeApiCancelMem", "Password1!", "홍길동", "19900101", "api-like-cancel@example.com", "010-6789-0123"); - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); HttpHeaders headers = headers("likeApiCancelMem", "Password1!"); int beforeLikeCount = getProductLikeCount(productId); @@ -226,7 +228,7 @@ void cancelLike_whenLikedProduct_returnsOk() { @DisplayName("좋아요가 없는 상품 취소 요청은 404을 반환한다") void cancelLike_whenNotLikedProduct_returnsNotFound() { registerMember("likeApiCancelMissMem", "Password1!", "홍길동", "19900101", "api-like-cancel-missing@example.com", "010-7890-1233"); - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); HttpHeaders headers = headers("likeApiCancelMissMem", "Password1!"); ResponseEntity> response = testRestTemplate.exchange( @@ -243,7 +245,7 @@ void cancelLike_whenNotLikedProduct_returnsNotFound() { @Test @DisplayName("인증 정보가 없으면 401을 반환한다") void cancelLike_whenNoAuthentication_returnsUnauthorized() { - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); ResponseEntity> response = testRestTemplate.exchange( productLikesUrl(productId), @@ -265,7 +267,7 @@ class MyLikes { @DisplayName("인증된 사용자가 기본 페이지/사이즈로 조회하면 200을 반환한다") void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() { registerMember("likeApiMeLikes", "Password1!", "홍길동", "19900101", "api-like-mylikes@example.com", "010-9012-3456"); - Long productId = createProduct("좋아요 상품", 10_000, 50); + UUID productId = createProduct("좋아요 상품", 10_000, 50); HttpHeaders headers = headers("likeApiMeLikes", "Password1!"); testRestTemplate.exchange( @@ -297,14 +299,14 @@ void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() { @SuppressWarnings("unchecked") java.util.List> items = (java.util.List>) data.get("items"); assertThat(items).hasSize(1); - assertThat(((Number) items.get(0).get("id")).longValue()).isEqualTo(productId); + assertThat(items.get(0).get("id")).isEqualTo(productId.toString()); } @Test @DisplayName("좋아요한 상품이 삭제되면 내 좋아요 목록에서 제외된다") void getMyLikes_whenLikedProductDeleted_excludesDeletedProduct() { registerMember("likeApiMeDeleted", "Password1!", "홍길동", "19900101", "api-like-mylikes-deleted@example.com", "010-9022-3456"); - Long productId = createProduct("삭제될 상품", 10_000, 50); + UUID productId = createProduct("삭제될 상품", 10_000, 50); HttpHeaders headers = headers("likeApiMeDeleted", "Password1!"); testRestTemplate.exchange( @@ -342,7 +344,7 @@ void getMyLikes_whenLikedProductDeleted_excludesDeletedProduct() { @DisplayName("브랜드 삭제 시 관련 상품 좋아요가 삭제되고 내 좋아요 목록에서 제외된다") void getMyLikes_whenBrandDeleted_removesRelatedLikesAndExcludesProducts() { registerMember("likeApiBrandDeleted", "Password1!", "홍길동", "19900101", "api-like-brand-deleted@example.com", "010-9032-3456"); - Long productId = createProduct("브랜드 삭제 대상 상품", 10_000, 50); + UUID productId = createProduct("브랜드 삭제 대상 상품", 10_000, 50); HttpHeaders headers = headers("likeApiBrandDeleted", "Password1!"); testRestTemplate.exchange( @@ -402,11 +404,11 @@ private HttpHeaders headers(String loginId, String password) { return headers; } - private String productLikesUrl(long productId) { + private String productLikesUrl(UUID productId) { return ENDPOINT_PRODUCTS + "/" + productId + ENDPOINT_LIKES; } - private int getProductLikeCount(long productId) { + private int getProductLikeCount(UUID productId) { ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, @@ -420,14 +422,14 @@ private int getProductLikeCount(long productId) { return response.getBody().data().path("likeCount").asInt(); } - private void deleteProductAsAdmin(long productId) { + private void deleteProductAsAdmin(UUID productId) { ProductEntity product = productJpaRepository.findById(productId) .orElseThrow(() -> new IllegalArgumentException("product not found: " + productId)); product.delete(); productJpaRepository.save(product); } - private void deleteBrandAsAdmin(long targetBrandId) { + private void deleteBrandAsAdmin(UUID targetBrandId) { HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_ADMIN_LDAP, ADMIN_LDAP_VALUE); @@ -461,7 +463,7 @@ private void registerMember(String loginId, String password, String name, String assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); } - private Long createProduct(String name, int price, int stock) { + private UUID createProduct(String name, int price, int stock) { ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( name, price, @@ -485,11 +487,11 @@ private Long createProduct(String name, int price, int stock) { return response.getBody().data().id(); } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } - private Long createBrand(String name) { + private UUID createBrand(String name) { return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java index d3a935468..23175c3a4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java @@ -19,7 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -29,10 +29,11 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") @DisplayName("Like API Controller Tests") class LikeControllerTest { @@ -58,8 +59,8 @@ class LikeControllerTest { @Autowired private ProductJpaRepository productJpaRepository; - private Long brandId; - private Long categoryId; + private UUID brandId; + private UUID categoryId; @BeforeEach void setUp() { @@ -80,7 +81,7 @@ class RegisterLike { @DisplayName("인증된 사용자가 활성 상품을 좋아요하면 201을 반환한다") void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() throws Exception { registerMember("likeMember", "Password1!", "홍길동", "19900101", "like@example.com", "010-1234-5678"); - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "likeMember") @@ -93,7 +94,7 @@ void registerLike_whenActiveProductAndAuthenticatedMember_returnsCreated() throw @DisplayName("이미 좋아요한 상품을 다시 요청하면 409을 반환한다") void registerLike_whenAlreadyLikedProduct_returnsConflict() throws Exception { registerMember("likeConflictMember", "Password1!", "홍길동", "19900101", "like2@example.com", "010-2345-6789"); - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "likeConflictMember") @@ -112,7 +113,7 @@ void registerLike_whenAlreadyLikedProduct_returnsConflict() throws Exception { void registerLike_whenProductNotFound_returnsNotFound() throws Exception { registerMember("likeMissingMem", "Password1!", "홍길동", "19900101", "missing@example.com", "010-3456-7890"); - mockMvc.perform(post("/api/v1/products/{productId}/likes", 0L) + mockMvc.perform(post("/api/v1/products/{productId}/likes", UUID.randomUUID()) .header(HEADER_LOGIN_ID, "likeMissingMem") .header(HEADER_LOGIN_PW, "Password1!")) .andExpect(status().isNotFound()); @@ -122,7 +123,7 @@ void registerLike_whenProductNotFound_returnsNotFound() throws Exception { @DisplayName("삭제된 상품은 400 Bad Request를 반환한다") void registerLike_whenDeletedProduct_returnsBadRequest() throws Exception { registerMember("likeDeletedMem", "Password1!", "홍길동", "19900101", "deleted@example.com", "010-4567-8901"); - Long productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); deleteProduct(productId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) @@ -134,7 +135,7 @@ void registerLike_whenDeletedProduct_returnsBadRequest() throws Exception { @Test @DisplayName("인증이 없으면 401을 반환한다") void registerLike_whenNoAuthentication_returnsUnauthorized() throws Exception { - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId)) .andExpect(status().isUnauthorized()); @@ -149,7 +150,7 @@ class CancelLike { @DisplayName("좋아요 상태면 취소하고 200을 반환한다") void cancelLike_whenLikedProduct_returnsOk() throws Exception { registerMember("cancelLikeMember", "Password1!", "홍길동", "19900101", "cancel@example.com", "010-5678-9012"); - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "cancelLikeMember") @@ -166,7 +167,7 @@ void cancelLike_whenLikedProduct_returnsOk() throws Exception { @DisplayName("좋아요가 없으면 404을 반환한다") void cancelLike_whenNotLikedProduct_returnsNotFound() throws Exception { registerMember("cancelMissingMem", "Password1!", "홍길동", "19900101", "cancel-missing@example.com", "010-6789-0123"); - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(delete("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "cancelMissingMem") @@ -177,7 +178,7 @@ void cancelLike_whenNotLikedProduct_returnsNotFound() throws Exception { @Test @DisplayName("인증이 없으면 401을 반환한다") void cancelLike_whenNoAuthentication_returnsUnauthorized() throws Exception { - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(delete("/api/v1/products/{productId}/likes", productId)) .andExpect(status().isUnauthorized()); @@ -192,7 +193,7 @@ class GetMyLikes { @DisplayName("인증된 사용자가 기본 페이지/사이즈로 조회하면 200을 반환한다") void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() throws Exception { registerMember("myLikesMember", "Password1!", "홍길동", "19900101", "mylikes@example.com", "010-7890-1234"); - Long productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("좋아요 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "myLikesMember") @@ -209,14 +210,14 @@ void getMyLikes_whenAuthenticatedMemberAndDefaultPagination_returnsOk() throws E .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.totalElements").value(1)) - .andExpect(jsonPath("$.data.items[0].id").value(productId)); + .andExpect(jsonPath("$.data.items[0].id").value(productId.toString())); } @Test @DisplayName("좋아요한 상품이 삭제되면 목록에서 제외된다") void getMyLikes_whenLikedProductDeleted_excludesDeletedProduct() throws Exception { registerMember("mylikesDeletedMem", "Password1!", "홍길동", "19900101", "mylikes-deleted@example.com", "010-7890-5555"); - Long productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); + UUID productId = createProduct("삭제될 상품", 10_000, 50, "테스트 상품", categoryId, brandId); mockMvc.perform(post("/api/v1/products/{productId}/likes", productId) .header(HEADER_LOGIN_ID, "mylikesDeletedMem") @@ -260,14 +261,14 @@ private void registerMember(String loginId, String password, String name, String .andExpect(status().isCreated()); } - private void deleteProduct(long productId) { + private void deleteProduct(UUID productId) { ProductEntity product = productJpaRepository.findById(productId) .orElseThrow(() -> new IllegalArgumentException("product not found: " + productId)); product.delete(); productJpaRepository.save(product); } - private Long createProduct(String name, int price, int stock, String description, Long categoryId, Long brandId) throws Exception { + private UUID createProduct(String name, int price, int stock, String description, UUID categoryId, UUID brandId) throws Exception { ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( name, price, @@ -285,14 +286,14 @@ private Long createProduct(String name, int price, int stock, String description .getResponse() .getContentAsString(); - return objectMapper.readTree(body).path("data").path("id").asLong(); + return UUID.fromString(objectMapper.readTree(body).path("data").path("id").asText()); } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } - private Long createBrand(String name) { + private UUID createBrand(String name) { return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java index 6cb28db5b..4ee39f34a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -15,7 +15,7 @@ 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.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; @@ -24,9 +24,10 @@ import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; +import java.util.UUID; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class ProductApiE2ETest { @@ -62,8 +63,8 @@ class ProductApi { @Test @DisplayName("상품 생성 후 상세 조회에 성공한다") void createAndGetDetail() { - Long categoryId = createCategory("푸드"); - Long brandId = createBrand("퍼피박스"); + UUID categoryId = createCategory("푸드"); + UUID brandId = createBrand("퍼피박스"); ProductDto.CreateProductRequest create = new ProductDto.CreateProductRequest( "강아지 샴푸", 8900, @@ -82,7 +83,7 @@ void createAndGetDetail() { ); assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); - Long productId = created.getBody().data().id(); + UUID productId = created.getBody().data().id(); ResponseEntity> detail = testRestTemplate.exchange( ENDPOINT_PRODUCTS + "/" + productId, @@ -99,9 +100,9 @@ void createAndGetDetail() { @Test @DisplayName("브랜드 필터로 목록 조회에 성공한다") void listWithBrandFilter() { - Long categoryId = createCategory("푸드"); - Long brandIdForList = createBrand("퍼피박스"); - Long otherBrandId = createBrand("포메피아"); + UUID categoryId = createCategory("푸드"); + UUID brandIdForList = createBrand("퍼피박스"); + UUID otherBrandId = createBrand("포메피아"); create("상품A", 1000, categoryId, brandIdForList); create("상품B", 2000, categoryId, otherBrandId); @@ -119,7 +120,7 @@ void listWithBrandFilter() { } } - private void create(String name, int price, Long categoryId, Long brandId) { + private void create(String name, int price, UUID categoryId, UUID brandId) { ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( name, price, @@ -138,11 +139,11 @@ private void create(String name, int price, Long categoryId, Long brandId) { ); } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } - private Long createBrand(String name) { + private UUID createBrand(String name) { return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java index 0a9ebd0ae..5055d71b8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java @@ -15,7 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -24,10 +24,11 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@ImportTestcontainers(MySqlTestContainersConfig.class) @ActiveProfiles("test") class ProductControllerTest { @@ -58,8 +59,8 @@ class Create { @Test @DisplayName("유효한 요청이면 201 Created를 반환한다") void createSuccess() throws Exception { - Long categoryId = createCategory("푸드"); - Long brandId = createBrand("퍼피박스"); + UUID categoryId = createCategory("푸드"); + UUID brandId = createBrand("퍼피박스"); ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( "강아지 간식", @@ -75,14 +76,14 @@ void createSuccess() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.id").isNumber()) + .andExpect(jsonPath("$.data.id").isString()) .andExpect(jsonPath("$.data.name").value("강아지 간식")); } @Test @DisplayName("카테고리 ID가 없으면 400을 반환한다") void createFailWhenCategoryIdMissing() throws Exception { - Long brandId = createBrand("퍼피박스"); + UUID brandId = createBrand("퍼피박스"); ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( "강아지 장난감", @@ -102,7 +103,7 @@ void createFailWhenCategoryIdMissing() throws Exception { @Test @DisplayName("브랜드 ID가 없으면 400을 반환한다") void createFailWhenBrandIdMissing() throws Exception { - Long categoryId = createCategory("푸드"); + UUID categoryId = createCategory("푸드"); ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( "강아지 목줄", @@ -127,21 +128,21 @@ class GetDetail { @Test @DisplayName("존재하는 상품이면 200과 상품 정보를 반환한다") void getDetailSuccess() throws Exception { - Long categoryId = createCategory("푸드"); - Long brandId = createBrand("퍼피박스"); - Long productId = createProduct("사료A", 12000, 30, "설명", categoryId, brandId); + UUID categoryId = createCategory("푸드"); + UUID brandId = createBrand("퍼피박스"); + UUID productId = createProduct("사료A", 12000, 30, "설명", categoryId, brandId); mockMvc.perform(get("/api/v1/products/{id}", productId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) - .andExpect(jsonPath("$.data.id").value(productId)) + .andExpect(jsonPath("$.data.id").value(productId.toString())) .andExpect(jsonPath("$.data.name").value("사료A")); } @Test @DisplayName("존재하지 않는 상품이면 404를 반환한다") void getDetailNotFound() throws Exception { - mockMvc.perform(get("/api/v1/products/{id}", 0L)) + mockMvc.perform(get("/api/v1/products/{id}", UUID.randomUUID())) .andExpect(status().isNotFound()); } } @@ -153,9 +154,9 @@ class GetList { @Test @DisplayName("브랜드 필터로 목록을 조회한다") void listByBrandFilter() throws Exception { - Long categoryId = createCategory("푸드"); - Long brandIdForList = createBrand("퍼피박스"); - Long otherBrandId = createBrand("포메피아"); + UUID categoryId = createCategory("푸드"); + UUID brandIdForList = createBrand("퍼피박스"); + UUID otherBrandId = createBrand("포메피아"); createProduct("사료A", 10000, 10, "설명", categoryId, brandIdForList); createProduct("사료B", 11000, 10, "설명", categoryId, otherBrandId); @@ -168,12 +169,12 @@ void listByBrandFilter() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.meta.result").value("SUCCESS")) .andExpect(jsonPath("$.data.totalElements").value(1)) - .andExpect(jsonPath("$.data.items[0].brandId").value(brandIdForList)) - .andExpect(jsonPath("$.data.items[0].categoryId").value(categoryId)); + .andExpect(jsonPath("$.data.items[0].brandId").value(brandIdForList.toString())) + .andExpect(jsonPath("$.data.items[0].categoryId").value(categoryId.toString())); } } - private Long createProduct(String name, int price, int stock, String description, Long categoryId, Long brandId) throws Exception { + private UUID createProduct(String name, int price, int stock, String description, UUID categoryId, UUID brandId) throws Exception { ProductDto.CreateProductRequest request = new ProductDto.CreateProductRequest( name, price, @@ -191,14 +192,14 @@ private Long createProduct(String name, int price, int stock, String description .getResponse() .getContentAsString(); - return objectMapper.readTree(body).path("data").path("id").asLong(); + return UUID.fromString(objectMapper.readTree(body).path("data").path("id").asText()); } - private Long createCategory(String name) { + private UUID createCategory(String name) { return categoryRepository.save(new Category(name)).id(); } - private Long createBrand(String name) { + private UUID createBrand(String name) { return brandRepository.save(new Brand(new BrandName(name), "", "")).id(); } } diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 841d87932..fb1d38a55 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -79,7 +79,6 @@ > > 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. > 결제 로직은 나중에 고려합니다 -> 쿠폰 로직은 나중에 고려합니다 반드시 포함되어야 할 설계 내용 @@ -135,13 +134,22 @@ "items": [ { "productId": 1, "quantity": 2 }, { "productId": 3, "quantity": 1 } - ] + ], + "couponId": 42 } ``` > **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! > **주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. > **주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> **쿠폰 적용 시에는 다음 동작이 보장되어야 합니다 :** 쿠폰 유효성 검증, 단일 사용 보장, 할인 금액 반영 + +### ✅ 주문 쿠폰 적용 규칙 + +- 주문 1건당 쿠폰 1장만 적용 가능 +- 존재하지 않거나 사용 불가한 쿠폰으로 요청 시 주문 실패 +- 주문 성공 시 쿠폰 상태는 즉시 `USED`로 변경되며 재사용 불가 +- 주문 스냅샷에는 쿠폰 적용 전 금액, 할인 금액, 최종 결제 금액 포함 --- @@ -155,6 +163,78 @@ --- +## 🎟 쿠폰 (Coupons) + +| **METHOD** | **URI** | **member_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/coupons/{couponId}/issue` | O | 쿠폰 발급 요청 | +| GET | `/api/v1/users/me/coupons` | O | 내 쿠폰 목록 조회 | + +> 쿠폰 목록 조회 시 사용 가능한 쿠폰(`AVAILABLE`) / 사용 완료(`USED`) / 만료(`EXPIRED`) 상태를 함께 반환합니다. + +### 🏷 쿠폰 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/coupons?page=0&size=20` | O | 쿠폰 템플릿 목록 조회 | +| GET | `/api-admin/v1/coupons/{couponId}` | O | 쿠폰 템플릿 상세 조회 | +| POST | `/api-admin/v1/coupons` | O | 쿠폰 템플릿 등록 (정액/FIXED, 정률/RATE) | +| PUT | `/api-admin/v1/coupons/{couponId}` | O | 쿠폰 템플릿 수정 | +| DELETE | `/api-admin/v1/coupons/{couponId}` | O | 쿠폰 템플릿 삭제 | +| GET | `/api-admin/v1/coupons/{couponId}/issues?page=0&size=20` | O | 특정 쿠폰 발급 내역 조회 | + +**쿠폰 템플릿 등록 요청 예시** + +```json +{ + "name": "신규가입 10% 할인", + "type": "RATE", + "value": 10, + "minOrderAmount": 10000, + "expiredAt": "2026-12-31T23:59:59" +} +``` + +--- + +## 🚀 Round 4 구현 보강 + +### Must-Have + +- DB 트랜잭션 +- Lock +- 동시성 테스트 +- 쿠폰 개념 + +### 주문 정합성 보장 요구 + +- 주문 시 재고/포인트/쿠폰 정합성을 트랜잭션으로 보장 +- Lost Update 방지를 위해 낙관적 락/비관적 락 중 도메인 특성에 맞는 전략 선택 +- 쿠폰/재고/포인트 처리 중 하나라도 실패 시 전체 롤백 + +### 동시성 테스트 요구 + +- 동일 상품 좋아요/취소 동시 요청 시 likeCount 정합성 보장 +- 동일 쿠폰 동시 주문 시 쿠폰 단일 사용 보장 +- 동일 상품 동시 주문 시 재고 음수 미발생 및 정상 차감 보장 + +--- + +## 🤖 트랜잭션 분석 Skills (문서 과제) + +- 트랜잭션 분석 Skill을 작성하고, 구현 기능에 대해 지속적으로 점검/개선합니다. +- 작성 예시 경로: `~/.claude/skills/anaylize-query/SKILL.md` +- 분석 범위: `@Transactional` 선언 지점, Service/Facade/Application, JPA/QueryDSL, 요청 흐름 단위 +- 분석 포인트: 트랜잭션 범위 과대 여부, 조회/쓰기 혼합 여부, flush/lazy loading 부작용, 락 경합 가능성 + +--- + +## ✍️ Technical Writing Quest + +- 이번 주 학습/과제에서 "무엇을"보다 "왜 그렇게 판단했는지" 중심으로 정리합니다. +- 글에는 반드시 1줄 요약(TL;DR)을 포함합니다. +- 반성문/요약문이 아닌, 실제 판단 흐름/트레이드오프/실패와 수정 과정을 담습니다. + ## 📡 나아가며 > ⚙️ **모든 기능의 동작을 개발한 후에 동시성, 멱등성, 일관성, 느린 조회, 동시 주문 등 실제 서비스에서 발생하는 문제들을 해결하게 됩니다.** diff --git a/docs/adr/2026-03-05-stock-deduction-concurrency-adr.md b/docs/adr/2026-03-05-stock-deduction-concurrency-adr.md new file mode 100644 index 000000000..38941c94d --- /dev/null +++ b/docs/adr/2026-03-05-stock-deduction-concurrency-adr.md @@ -0,0 +1,116 @@ +# ADR: 주문 핵심 동시성 제어 전략 (재고 / 좋아요 / 쿠폰 사용) + +- 상태: Accepted +- 날짜: 2026-03-05 +- 작성자: OpenCode +- 관련 모듈: `apps/commerce-api` + +## 맥락 + +주문 핵심 흐름에서 동시성 경합이 집중되는 구간은 다음 3개다. + +1. 재고 차감 +2. 좋아요 수 변경 +3. 쿠폰 사용/사용 취소 + +기존에는 재고 중심으로만 전략 비교가 있었고, 좋아요/쿠폰은 “원자적 업데이트 확정” 결정이 문서로 통합되지 않아 운영 판단 근거가 분산되어 있었다. + +## 최종 결정 + +### 1) 재고(Stock) + +재고는 도메인 특성(품절/충돌/처리량)과 운영 전략 비교를 위해 3전략을 유지한다. + +- 원자 업데이트: `update ... set stock = stock - :q where stock >= :q` +- 낙관락: `@Version` + `@Lock(OPTIMISTIC)` + `saveAndFlush` +- 비관락: `@Lock(PESSIMISTIC_WRITE)` 조회 후 차감 + +### 2) 좋아요(Like) + +좋아요는 **원자적 업데이트로 확정**한다. + +- 경로: `ProductLikeAplicationService` -> `ProductRepository.updateLikeCount` -> JPQL `update` +- 선택 이유: 단순 카운터 성격이며, 행 단위 원자 증가/감소가 경합 대비 효율적 + +### 3) 쿠폰 사용(Coupon Use) + +쿠폰 사용/사용 취소는 **원자적 업데이트로 확정**한다. + +- 사용: `AVAILABLE -> USED` 조건부 update +- 사용 취소: `USED -> AVAILABLE` 조건부 update +- 조건 실패(0 row update)는 `CONFLICT`로 해석 + +## 구현 요약 + +### 메인 코드 + +- 재고 + - `ProductStockApplicationService`: 원자/낙관/비관 메서드 분리 + - `ProductJpaRepository`: `@Lock(PESSIMISTIC_WRITE)`, `@Lock(OPTIMISTIC)`, `decreaseStockAtomically` + - `ProductEntity`: `@Version`, `@DynamicUpdate` + +- 좋아요 + - `ProductLikeAplicationService`: `increaseLikeCount/decreaseLikeCount` 모두 원자 update 사용 + - `ProductJpaRepository.updateLikeCount`: 음수 방지 케이스 포함 원자 쿼리 + +- 쿠폰 + - `CouponApplicationService.use`: `markUsedAtomically` + - `CouponApplicationService.cancelUse`: `markAvailableAtomically` + - `IssuedCouponJpaRepository`: 사용/사용취소 모두 `@Modifying` 조건부 update + +- 주문 취소 연계 + - `OrderUseCase.cancel`: 주문 취소 후 재고 복구 + 쿠폰 사용 취소 호출 + - 관리자 취소(`memberId=null`)에서도 주문 소유자(`cancelled.memberId()`)로 쿠폰 복구 + +## 테스트 근거 + +### 재고 전략 동시성 + +- 실행 명령: + - `./gradlew :apps:commerce-api:compileTestJava :apps:commerce-api:test --tests "com.loopers.application.product.ProductStockApplicationServiceConcurrencyTest"` + - `./gradlew :apps:commerce-api:test --tests "com.loopers.application.product.ProductStockApplicationServiceConcurrencyTest" --rerun-tasks --info` +- 결과: `BUILD SUCCESSFUL` +- 참고: 낙관락 경로는 테스트 코드에서 `CONFLICT` 발생 시 최대 5회 재시도 + +| 전략 | 요청수 | 성공 | 품절 | 충돌 | 기타실패 | 소요시간(ms) | 처리량(success/sec) | 최종재고 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| PESSIMISTIC | 20 | 10 | 10 | 0 | 0 | 54 | 184.27 | 0 | +| ATOMIC UPDATE | 20 | 10 | 10 | 0 | 0 | 37 | 266.69 | 0 | +| OPTIMISTIC (retry 5) | 20 | 10 | 3 | 7 | 0 | 201 | 49.66 | 0 | + +### 좋아요(원자) + 재고(전략별) 혼합 동시성 + +- 실행 결과: `BUILD SUCCESSFUL` (`ProductStockApplicationServiceConcurrencyTest`) + +| 재고 전략 | like 요청수 | stock 요청수 | like 성공 | like 실패 | stock 성공 | stock 품절 | stock 충돌 | stock 기타실패 | 소요시간(ms) | like 처리량 | stock 처리량 | 최종 likeCount | 최종 stock | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| ATOMIC UPDATE | 20 | 20 | 20 | 0 | 10 | 10 | 0 | 0 | 77 | 259.48 | 129.74 | 20 | 0 | +| PESSIMISTIC | 20 | 20 | 20 | 0 | 10 | 10 | 0 | 0 | 88 | 225.70 | 112.85 | 20 | 0 | +| OPTIMISTIC (retry 5) | 20 | 20 | 20 | 0 | 10 | 0 | 10 | 0 | 172 | 115.97 | 57.99 | 20 | 0 | + +### 쿠폰 사용/취소 동시성 + +- `CouponUseConcurrencyTest` + - 동일 couponId 동시 주문 시 1건만 성공 + - 동일 couponId 사용 취소 동시 요청 시 1건만 성공 + +- `OrderUseCaseIntegrationTest` + - 주문 취소 시 쿠폰 복구 후 재주문에서 동일 쿠폰 재사용 가능 + - 관리자 취소에서도 쿠폰 복구 동작 + +## 트레이드오프 + +- 재고 + - 비관락: 정합성 강함, 대기 시간 증가 가능 + - 낙관락: 저경합 유리, 고경합 충돌 비용 큼 + - 원자 업데이트: 단순/고성능, 복합 비즈니스 규칙 확장 시 제약 가능 + +- 좋아요/쿠폰 + - 원자 update는 단일 상태 전이에 매우 적합 + - 다단계 상태 머신이나 외부 연동이 추가되면 별도 락/보상 설계 필요 + +## 후속 작업 + +1. 재고 전략 반복 측정(워밍업 + N회)로 평균/분산 지표 보강 +2. 쿠폰 발급(issue) 경로도 원자화 필요 여부를 정책 차원에서 결정 +3. 운영 장애 대응 문서에 충돌(`CONFLICT`) 재시도 가이드 추가 diff --git a/docs/checklist.md b/docs/checklist.md index 0c4b74525..322dd1a01 100644 --- a/docs/checklist.md +++ b/docs/checklist.md @@ -26,13 +26,24 @@ - [ ] 주문 시 상품의 재고 차감을 수행한다 - [ ] 재고 부족 예외 흐름을 고려해 설계되었다 - [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 -- [ ] 주문 시점 스냅샷(상품명/가격/브랜드명)을 OrderItem에 저장한다 +- [ ] 주문 시점 스냅샷(상품 ID/상품명/가격/브랜드명)을 OrderItem에 저장한다 +- [ ] OrderItem.productId는 FK 없이 논리 참조로 보관해 추적성을 확보한다 - [ ] 일부 상품 실패 시 전체 주문 실패(부분 성공 없음) 정책을 반영했다 - [ ] 주문 취소 시 상태 전이(ORDERED -> CANCELLED) 및 재고 복원을 반영했다 - [ ] 이미 취소된 주문 재취소는 409로 처리한다 - [ ] 주문 상세/목록 조회는 스냅샷 기준으로 응답한다 - [ ] 주문 목록 조회는 기간(startAt/endAt) + 페이지네이션을 반영한다 +### 🎟 Coupon 도메인 + +- [ ] 쿠폰 템플릿은 정액(FIXED)/정률(RATE) 타입을 지원한다 +- [ ] 쿠폰은 사용자 소유 개념을 가지며, 발급 이력이 관리된다 +- [ ] 쿠폰 상태(`AVAILABLE`/`USED`/`EXPIRED`)를 조회 응답에 포함한다 +- [ ] 이미 사용/만료/타인 소유 쿠폰은 주문에 적용할 수 없다 +- [ ] 쿠폰은 주문 1건당 1장만 적용된다 +- [ ] 주문 성공 시 쿠폰 상태가 즉시 `USED`로 전이된다 (재사용 불가) +- [ ] 주문 스냅샷에는 원금액/할인금액/최종금액이 포함된다 + ### 🧩 도메인 서비스 - [ ] 엔티티/VO 단독으로 표현하기 어려운 도메인 내부 규칙만 Domain Service에 둔다 @@ -53,6 +64,9 @@ - [ ] Facade는 여러 Application Service 조합/오케스트레이션이 필요한 경우에만 사용한다 - [ ] `@Transactional`은 Application Service에만 둔다 - [ ] 주문 유스케이스는 단일 트랜잭션 경계에서 재고확인 -> 차감 -> 주문생성 흐름을 보장한다 +- [ ] 주문 유스케이스는 쿠폰 검증/사용, 재고 차감, 주문 저장의 원자성을 보장한다 +- [ ] 동시성 제어 전략(낙관적/비관적 락) 선택 근거를 설계 문서에 명시했다 +- [ ] DB 제약조건으로 쿠폰 단일 사용을 강제한다 ### 👤 Member / 인증 @@ -77,3 +91,10 @@ - [ ] 통합 테스트는 Testcontainers 기반으로 운영과 동일 엔진/버전을 사용한다 - [ ] 보안 회귀: `toString` 민감정보 마스킹 테스트를 포함한다 - [ ] 경계값: 이름 마스킹 1/2/3글자 테스트를 포함한다 + +### ⚔️ 동시성 테스트 + +- [ ] 동일 상품에 대한 동시 좋아요/취소 요청 후 likeCount 정합성을 검증한다 +- [ ] 동일 couponId 동시 주문 요청에서 1건만 성공함을 검증한다 +- [ ] 동일 상품 동시 주문 요청에서 재고 음수 미발생을 검증한다 +- [ ] 실패 트랜잭션에서 재고/쿠폰/주문 데이터 롤백을 검증한다 diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 1659d5979..bfa89f011 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -284,7 +284,7 @@ **비즈니스 규칙:** - 주문 상태: ORDERED로 생성 -- 스냅샷: 주문 시점의 주문 번호, 상품명, 가격, 브랜드명 저장 +- 스냅샷: 주문 시점의 주문 번호, productId(논리 참조), 상품명, 가격, 브랜드명 저장 - 하나의 트랜잭션에서 재고 확인 → 차감 → 주문 생성 처리 - 결제 없음 (주문 완료 = 결제 완료) @@ -361,19 +361,82 @@ - **아키텍처**: interfaces → application(Facade) → domain(Service) ← infrastructure(Repository) - **테스트**: 단위 + 통합 + E2E, MySQL TestContainer 사용 +### 트랜잭션/락/정합성 요구 + +- 주문 유스케이스에서 재고, 쿠폰, 주문 생성은 하나의 비즈니스 트랜잭션으로 다뤄져야 하며 부분 성공이 없어야 한다. +- 동시성 제어는 도메인 특성에 따라 낙관적 락/비관적 락 중 선택하며, 선택 근거를 문서화한다. +- 쿠폰 단일 사용 보장은 DB 제약조건(유니크/상태 전이 조건)과 애플리케이션 검증을 함께 사용한다. +- 트랜잭션 실패 시 쿠폰 상태/재고/주문 데이터는 원자적으로 롤백되어야 한다. + +### 동시성 테스트 요구 + +- 동일 상품 좋아요/취소 동시 요청에서도 likeCount 정합성을 보장한다. +- 동일 쿠폰으로 동시 주문 시 쿠폰은 한 번만 `USED` 전이되어야 한다. +- 동일 상품 동시 주문 시 재고는 음수가 되지 않고 성공 주문 수만큼만 차감되어야 한다. + ## 5. Scope-out (명시적 제외) - **결제 시스템** - 주문 완료 = 결제 완료로 간주. 추후 추가 개발 -- **쿠폰/프로모션** - 프로젝트 명세에서 "나중에 고려"로 명시 - **포인트 충전** - 프로젝트 명세에서 명시적으로 제외 - **추천/랭킹 알고리즘** - 데이터 축적이 먼저. 좋아요/주문 데이터 구조만 확보 - **리뷰/별점** - 명세에 없음 - **배송/물류** - 명세에 없음 -- **동시성/멱등성 고도화** - 기본 기능 개발 후 해결 (프로젝트 명세에 명시) - **JWT/세션 인증** - 헤더 기반 식별로 확정 - **Rate Limiting** - 인프라 레벨, MVP 범위 밖 -## 6. 미결정 사항 +## 6. Coupon 요구사항 + +### 도메인 6: Coupon + +#### US-21: 쿠폰 템플릿 등록 (어드민) + +**As a** 어드민 +**I want to** 정액(FIXED) 또는 정률(RATE) 쿠폰 템플릿을 등록 +**So that** 회원이 주문 시 할인 혜택을 적용할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given LDAP 인증 + 유효한 입력값, When POST /api-admin/v1/coupons, Then 201 +- [ ] AC2: Given 잘못된 type/value/minOrderAmount, When POST, Then 400 +- [ ] AC3: Given 만료일이 현재 이전, When POST, Then 400 + +#### US-22: 쿠폰 발급 (회원) + +**As a** 로그인한 회원 +**I want to** 쿠폰을 발급받고 보유 목록에서 확인 +**So that** 주문 시 할인 혜택을 받을 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증된 회원 + 유효한 couponId, When POST /api/v1/coupons/{couponId}/issue, Then 201 +- [ ] AC2: Given 존재하지 않는 couponId, When POST, Then 404 +- [ ] AC3: Given 만료된 쿠폰 템플릿, When POST, Then 400 + +#### US-23: 내 쿠폰 목록 조회 + +**As a** 로그인한 회원 +**I want to** 내 쿠폰 목록과 상태를 조회 +**So that** 사용 가능 여부를 확인할 수 있다 + +**수용 기준 (AC):** +- [ ] AC1: Given 인증된 회원, When GET /api/v1/users/me/coupons, Then 200 + 상태(AVAILABLE/USED/EXPIRED) 포함 목록 + +#### US-24: 주문 시 쿠폰 적용 + +**As a** 로그인한 회원 +**I want to** 주문에 쿠폰을 1장 적용 +**So that** 최종 결제 금액을 할인받는다 + +**수용 기준 (AC):** +- [ ] AC1: Given 유효한 소유 쿠폰 + 재고 충분, When POST /api/v1/orders, Then 201 + 쿠폰 USED 전이 +- [ ] AC2: Given 이미 사용된 쿠폰/만료 쿠폰/타인 쿠폰, When POST, Then 400 또는 403으로 주문 실패 +- [ ] AC3: Given 동시 주문 경쟁, Then 동일 쿠폰은 단 1건만 성공 + +**비즈니스 규칙:** +- 주문 1건당 쿠폰 1장 +- 할인 계산: FIXED(정액 차감), RATE(주문금액 * 퍼센트) +- 최소 주문 금액 조건(minOrderAmount) 미충족 시 적용 불가 +- 주문 스냅샷: 원금액, 할인금액, 최종금액 저장 + +## 7. 미결정 사항 없음 (모두 해소됨) @@ -383,7 +446,7 @@ - startAt/endAt: KST (Asia/Seoul) 기준, 날짜만 받고 KST 00:00~23:59로 처리 → 확정 - 좋아요 목록: page/size 페이지네이션 추가 → 확정 -## 7. 용어 사전 +## 8. 용어 사전 | 용어 | 정의 | |------|------| @@ -393,8 +456,8 @@ | Product | 상품 (개별 애견용품) | | Like | 좋아요 (멤버의 상품 관심 표시) | | Order | 주문 (하나 이상의 상품을 포함하는 구매 요청) | -| OrderItem | 주문 항목 (주문 내 개별 상품 + 수량 + 스냅샷) | -| Snapshot | 스냅샷 (주문 시점의 상품 정보 사본 — 주문 번호, 상품명, 가격, 브랜드명) | +| OrderItem | 주문 항목 (주문 내 개별 상품 + 수량 + 스냅샷 + productId 논리 참조) | +| Snapshot | 스냅샷 (주문 시점의 상품 정보 사본 — 주문 번호, productId(논리 참조), 상품명, 가격, 브랜드명) | | likeCount | 상품별 좋아요 수 (Product 테이블 필드) | | LDAP | 어드민 인증 헤더 (X-Loopers-Ldap: loopers.admin) | | Soft Delete | deletedAt 필드 기반 논리적 삭제 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index e1d2e313d..e33818c52 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -2,54 +2,56 @@ ## 주문 요청 (POST /api/v1/orders) -주문 생성은 이 시스템에서 가장 복잡한 로직이다. 상품 활성 상태 확인 → 재고 확인 → 재고 차감 → 스냅샷 생성 → 주문 저장이 원자적으로 처리되는지, 실패 시 전체 롤백이 보장되는지 검증한다. +주문 생성은 이 시스템에서 가장 복잡한 로직이다. 재고 차감, 쿠폰 사용, 주문 생성이 하나의 유스케이스 트랜잭션에서 원자적으로 처리되고, 실패 시 전체 롤백되는지 검증한다. ```mermaid sequenceDiagram autonumber participant C as Client participant OC as OrderController - participant OF as OrderFacade - participant PS as ProductService - participant OS as OrderService - - C->>OC: POST /api/v1/orders (items) - OC->>OF: 주문 생성 요청 - - loop 각 OrderItem에 대해 - OF->>PS: 활성 상품 조회 및 재고 검증 - PS-->>OF: 상품 정보 반환 + participant OU as OrderCreateUseCase + participant PS as ProductStockApplicationService + participant CS as CouponApplicationService + participant OS as OrderApplicationService + participant DB as DB + + C->>OC: POST /api/v1/orders (items, couponId?) + OC->>OU: 주문 생성 유스케이스 실행 + note over OU: @Transactional 시작 + + OU->>PS: 재고 예약/차감(락 기반) + PS->>DB: 상품 행 잠금 + 재고 검증/차감 + DB-->>PS: 예약 완료 + + alt couponId 있음 + OU->>CS: 쿠폰 검증/사용 + CS->>DB: 소유자/만료/상태 검증 + USED 전이 + DB-->>CS: 성공 또는 실패 end - alt 삭제된 상품 포함 - OF-->>OC: 400 Bad Request - OC-->>C: 400 (삭제된 상품) - else 재고 부족 - OF-->>OC: 400 Bad Request - OC-->>C: 400 (재고 부족) - else 모든 검증 통과 - note over OF: 스냅샷 생성 (주문 번호, 상품명, 가격, 브랜드명) - - loop 각 OrderItem에 대해 - OF->>PS: 재고 차감 요청 - PS-->>OF: 차감 완료 - end - - OF->>OS: 주문 생성 (스냅샷 포함) - OS-->>OF: 주문 생성 완료 - - OF-->>OC: 주문 정보 반환 + OU->>OS: 주문 생성(스냅샷 포함) + OS->>DB: Order + OrderItem + CouponSnapshot 저장 + DB-->>OS: 저장 완료 + + alt 중간 실패 발생 + OU-->>OC: 예외 전달 + note over OU: 트랜잭션 롤백 + OC-->>C: 4xx/5xx + else 성공 + note over OU: 트랜잭션 커밋 + OU-->>OC: 주문 정보 반환 OC-->>C: 201 Created end ``` ### 핵심 포인트 - **전체 실패 정책**: 여러 상품 중 하나라도 문제가 있으면 전체 주문이 실패한다 (부분 성공 없음). -- **스냅샷 시점**: Facade에서 검증 완료된 상품 정보로 스냅샷을 생성한 후, OrderService에 전달. +- **유스케이스 중심**: Controller는 유스케이스를 호출하고, 유스케이스 내부에서 재고/쿠폰/주문 흐름을 오케스트레이션한다. +- **트랜잭션 경계**: 주문 유스케이스(`@Transactional`)에서 재고 차감 + 쿠폰 사용 + 주문 저장을 원자적으로 처리한다. ### 설계 리스크 -- **크로스 도메인 원자성**: 재고 차감과 주문 저장이 별도 트랜잭션이므로, 주문 저장 실패 시 재고 복원 보상 로직 필요. -- **재고 동시성**: Facade의 읽기 검증과 재고 차감 사이에 갭이 존재. `WHERE stock >= quantity` 조건으로 해결 가능. +- **락 경합**: 동시 주문이 몰리면 상품/쿠폰 락 대기가 길어질 수 있다. 락 순서 고정과 짧은 트랜잭션 유지가 필요. +- **쿠폰 만료 판정**: 도메인 정책(상태/시간)과 저장 정책(ERD) 간 불일치가 있으면 경계 시점 버그가 발생할 수 있다. --- @@ -62,42 +64,55 @@ sequenceDiagram autonumber participant C as Client participant OC as OrderController - participant OF as OrderFacade + participant OU as OrderCancelUseCase participant OS as OrderService participant PS as ProductService + participant CS as CouponApplicationService + participant DB as DB C->>OC: PATCH /orders/{orderId}/cancel - OC->>OF: 주문 취소 요청 + OC->>OU: 주문 취소 유스케이스 실행 + note over OU: @Transactional 시작 - OF->>OS: 주문 + 주문항목 조회 - OS-->>OF: 주문 정보 반환 + OU->>OS: 주문 + 주문항목 조회 + OS-->>OU: 주문 정보 반환 - note over OF: 권한 확인 (고객: 본인만, 어드민: 모두) + note over OU: 권한 확인 (고객: 본인만, 어드민: 모두) - OF->>OS: 주문 취소 처리 + OU->>OS: 주문 취소 처리 alt 이미 CANCELLED - OS-->>OF: 409 Conflict - OF-->>OC: 409 Conflict + OS-->>OU: 409 Conflict + OU-->>OC: 409 Conflict OC-->>C: 409 (이미 취소됨) else ORDERED 상태 - OS-->>OF: 취소 완료 + OS-->>OU: 취소 완료 loop 각 OrderItem에 대해 - OF->>PS: 재고 복원 요청 - PS-->>OF: 복원 완료 + OU->>PS: 재고 복원 요청 + PS->>DB: 재고 증가 + DB-->>PS: 복원 완료 + end + + alt 주문에 적용된 쿠폰 있음 + OU->>CS: 쿠폰 사용 취소(AVAILABLE 복원) + CS->>DB: 쿠폰 상태 복원 + DB-->>CS: 복원 완료 end - OF-->>OC: 취소 완료 + note over OU: 트랜잭션 커밋 + OU-->>OC: 취소 완료 OC-->>C: 200 OK end ``` ### 핵심 포인트 -- **권한 분기**: Facade에서 권한을 확인한 후 (고객: 본인만, 어드민: 모두), 취소 로직을 진행. +- **유스케이스 중심**: OrderCancelUseCase가 권한 확인, 주문 취소, 재고 복원, 쿠폰 복원을 오케스트레이션한다. +- **트랜잭션 경계**: 주문 취소 유스케이스(`@Transactional`)에서 취소/복원 동작을 원자적으로 처리한다. ### 설계 리스크 - **삭제된 상품의 재고 복원**: 주문 후 상품이 Soft Delete된 경우, 취소 시 재고를 복원해야 하는지 정책 결정 필요. 현재는 복원하는 것으로 가정. +- **쿠폰 복원 정책**: 주문 취소 시 쿠폰 재사용 허용 여부(AVAILABLE 복원) 정책을 명확히 합의해야 한다. --- @@ -210,3 +225,5 @@ sequenceDiagram ### 설계 리스크 - **정렬 성능**: likes_desc 정렬 시 likeCount 컬럼에 인덱스가 없으면 대량 데이터에서 성능 저하 가능. 인덱스 추가로 해결. + +--- diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index ee55e3432..ebf85d5cf 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -7,6 +7,7 @@ ```mermaid classDiagram class Brand { + -UUID id -String name -String description -String imageUrl @@ -14,10 +15,12 @@ classDiagram } class Category { + -UUID id -String name } class Product { + -UUID id -String name -int price -int stock @@ -31,10 +34,14 @@ classDiagram } class Like { + -UUID id + -UUID memberId + -UUID productId -LocalDateTime likedAt } class Member { + -UUID id -MemberId loginId -Password password -Name name @@ -46,14 +53,21 @@ classDiagram } class Order { + -UUID id + -UUID memberId -String orderNumber -LocalDateTime orderDate -OrderStatus status + -int originalAmount + -int discountAmount -int totalAmount +cancel() } class OrderItem { + -UUID id + -UUID orderId + -UUID productId -int quantity -String snapshotProductName -int snapshotPrice @@ -66,26 +80,110 @@ classDiagram CANCELLED } + class Coupon { + -UUID id + -String name + -CouponType type + -int value + -int minOrderAmount + -LocalDateTime expiredAt + +calculateDiscount(orderAmount) int + +isUsableAt(now) boolean + } + + class IssuedCoupon { + -UUID id + -UUID memberId + -UUID couponId + -CouponStatus status + -LocalDateTime issuedAt + -LocalDateTime expiredAt + -LocalDateTime usedAt + +validateOwner(memberId) + +validateUsable(now) + +markUsed() + } + + class CouponSnapshot { + -CouponType type + -String name + -int value + -int minOrderAmount + } + + class CouponType { + <> + FIXED + RATE + } + + class CouponStatus { + <> + AVAILABLE + USED + EXPIRED + } + Brand "1" -- "*" Product : 보유한다 Category "1" -- "*" Product : 분류한다 Product "1" -- "*" Like : 받는다 Product "1" ..> "*" OrderItem : 스냅샷으로 캡처 Member "1" -- "*" Like : 좋아요한다 Member "1" -- "*" Order : 주문한다 + Member "1" -- "*" IssuedCoupon : 보유한다 + Coupon "1" -- "*" IssuedCoupon : 발급된다 + Order "0..1" --> "0..1" IssuedCoupon : 적용한다 + Order "0..1" *-- "0..1" CouponSnapshot : 스냅샷 Order "1" *-- "*" OrderItem : 포함한다 Order -- OrderStatus + Coupon -- CouponType + IssuedCoupon -- CouponStatus + CouponSnapshot -- CouponType ``` ## 핵심 포인트 - **불변 도메인 객체**: 기존 Member가 record 기반 불변 객체 + Value Object(MemberId, Password, Name, Email, BirthDate) 패턴으로 구현되어 있다. 새 도메인도 동일 패턴 적용. - **엔티티에 비즈니스 로직 배치**: Product.decreaseStock(), Order.cancel() 등 상태 변경 로직이 Service가 아닌 엔티티 자체에 위치하여 빈약한 도메인 방지. -- **스냅샷 분리 (점선)**: OrderItem은 Product의 런타임 참조를 갖지 않는다. 주문 시점의 상품명/가격/브랜드명을 스냅샷 필드로 복사하여 Product 변경/삭제에 영향받지 않음. +- **스냅샷 분리 (점선)**: OrderItem은 Product의 런타임 참조를 갖지 않는다. 다만 `productId`를 논리 참조로 보관하고, 주문 시점의 상품명/가격/브랜드명 스냅샷을 함께 저장해 변경/삭제에도 주문 이력을 보존한다. - **Like = 조인 엔티티**: Member-Product 간 N:M 관계를 Like 엔티티로 풀어낸다. DB에서 (memberId + productId) Unique 제약조건으로 중복 방지. - **Brand.name 불변**: updateInfo()는 description, imageUrl만 수정 가능. name은 생성 시 확정. - **Category는 Seed 데이터**: 비즈니스 메서드 없음. 조회 전용 참조 테이블. +- **쿠폰 분리 모델**: Coupon(정책)과 IssuedCoupon(개인 소유/상태 전이)을 분리해 단일 사용/만료/소유권 규칙을 명확히 한다. + +## Coupon 도메인 설계 (1차) + +이번 단계에서는 쿠폰 도메인의 최소 책임과 상태 전이 규칙을 먼저 고정한다. + +### Aggregate 경계 + +- **Coupon**: 할인 정책의 원본(타입, 값, 최소 주문 금액, 만료 시각)을 소유 +- **IssuedCoupon**: 사용자에게 발급된 쿠폰 인스턴스(소유자, 상태, 사용 시각)를 소유 +- **Order는 IssuedCoupon을 0..1로 참조**: 주문당 쿠폰 1장 규칙 반영 +- **Order는 CouponSnapshot을 보관**: 주문 시점 쿠폰 타입/이름/값/최소주문금액을 고정 저장 + +### 도메인 규칙 + +- `Coupon` + - `type=FIXED`면 `value`는 할인 금액(원) + - `type=RATE`면 `value`는 퍼센트(1~100) + - `minOrderAmount` 미충족 시 적용 불가 + - `expiredAt` 이후 신규 발급/적용 불가 +- `IssuedCoupon` + - 개인 만료 정책을 위해 `expiredAt`을 별도로 가진다 + - 상태 전이: `AVAILABLE -> USED` 단방향 + - `USED`, `EXPIRED` 상태는 주문 적용 불가 + - 도메인에서 만료 시점 도달 시 `EXPIRED` 상태 전이를 허용한다 + - 소유자(memberId)와 주문 요청자 불일치 시 적용 불가 + +### 1차 설계 의도 + +- 쿠폰 정책(Template)과 사용자 소유 상태(Issued)를 분리해 상태 전이 규칙을 단순화 +- 주문에서 쿠폰은 선택(`0..1`)으로 유지해 기존 주문 흐름 영향 최소화 +- 동시성 제어는 다음 단계에서 트랜잭션/락 설계 문서와 결합해 구체화 ## 설계 리스크 - **Product 상태 변경의 동시성**: decreaseStock(), increaseLikeCount() 등이 동시 호출될 때 경합 발생 가능. 엔티티 레벨에서는 검증만 수행하고, 동시성 제어는 인프라(DB 락/조건부 UPDATE)에서 해결. -- **OrderItem 스냅샷 필드 확장**: 현재 상품명/가격/브랜드명 3개. 향후 카테고리, 이미지 등 스냅샷 대상이 늘어나면 OrderItem이 비대해질 수 있다. 현재 요구사항에서는 3개로 충분. +- **OrderItem 스냅샷 필드 확장**: 현재 `productId(논리 참조)` + 상품명/가격/브랜드명을 저장한다. 향후 카테고리, 이미지 등 스냅샷 대상이 늘어나면 OrderItem이 비대해질 수 있다. +- **쿠폰 상태 전이 경합**: IssuedCoupon의 `AVAILABLE -> USED` 전이에서 경쟁 조건이 발생할 수 있으므로, 전이 조건과 DB 제약으로 단일 사용을 보장해야 한다. diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 06532752e..bb5552af8 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -32,13 +32,29 @@ erDiagram datetime orderDate string status int totalAmount + int originalAmount + int discountAmount } ORDER_ITEM { + bigint productId int quantity string snapshotProductName int snapshotPrice string snapshotBrandName } + COUPON { + string name + string type + int value + int minOrderAmount + datetime expiredAt + } + ISSUED_COUPON { + string memberId + string status + datetime expiredAt + datetime usedAt + } USER ||--o{ ORDER : "주문한다" LIKE { @@ -50,12 +66,17 @@ erDiagram ORDER ||--|{ ORDER_ITEM : "포함한다" BRAND ||--o{ PRODUCT : "보유한다" CATEGORY ||--o{ PRODUCT : "분류한다" + USER ||--o{ ISSUED_COUPON : "소유한다" + COUPON ||--o{ ISSUED_COUPON : "발급된다" + ORDER o|--o| ISSUED_COUPON : "사용한다" ``` ## 핵심 포인트 - **Like = Member-Product N:M 조인 엔티티**: Member와 Product 간 N:M 관계를 Like 엔티티로 풀어냈다. DB에서 (memberId + productId) Unique 제약조건으로 중복 방지. Like 자체에 비즈니스 속성은 없으므로 속성 블록을 생략했다. -- **OrderItem 스냅샷 비정규화**: OrderItem은 Product와 FK 관계가 없다. 주문 시점의 상품명/가격/브랜드명을 자체 필드에 복사하여, Product 변경/삭제에 영향받지 않는 독립적 데이터로 존재한다. ERD에서 Product-OrderItem 간 관계선이 없는 이유. +- **OrderItem 스냅샷 비정규화**: OrderItem은 Product와 FK 관계가 없다. 다만 `productId`를 논리 참조로 함께 저장해 역추적성을 확보하고, 주문 시점의 상품명/가격/브랜드명은 스냅샷으로 고정한다. ERD에서 Product-OrderItem 간 관계선이 없는 이유. - **Order-OrderItem 컴포지션**: Order 삭제 시 OrderItem도 함께 삭제되는 강한 소유 관계. 최소 1개 이상의 OrderItem이 필요하다 (`||--|{`). +- **쿠폰 모델 분리**: 쿠폰 정책(COUPON)과 개인 보유 쿠폰(ISSUED_COUPON)을 분리해 소유권/상태 전이를 표현한다. +- **ERD 만료 정책(B안)**: ISSUED_COUPON의 상태는 `AVAILABLE`/`USED`만 저장하고, 만료는 `expiredAt` 비교로 판단한다. ## 엔티티 삭제 전략 | 엔티티 | 삭제 전략 | 이유 | @@ -66,8 +87,12 @@ erDiagram | PRODUCT | Soft Delete | 주문 스냅샷 및 좋아요 이력과의 추적성 유지 | | ORDER | Soft Delete | 감사/정산 목적 보관 필요 | | ORDER_ITEM | Soft Delete | 주문 감사 추적 일관성 유지 | +| COUPON | Soft Delete | 과거 발급/사용 이력 추적 필요 | +| ISSUED_COUPON | Soft Delete | 주문 이력 정합성 및 감사 추적 필요 | | LIKE | Hard Delete | 사용자 취소 가능한 임시 관계 데이터 | ## 설계 리스크 - **likeCount 비정규화**: Product.likeCount는 Like 테이블의 COUNT와 동기화되어야 한다. 좋아요 등록/취소 시 별도 트랜잭션에서 업데이트하므로, 일시적 불일치 가능성 있음. 선택지: (A) 현재 설계 유지 + 주기적 보정 배치 (B) likeCount 제거하고 매번 COUNT 쿼리. -- **OrderItem-Product 참조 부재**: 스냅샷 패턴으로 런타임 참조가 없으므로, "이 주문 항목이 어떤 상품이었는지" 역추적이 스냅샷 필드(상품명)에 의존한다. 선택지: (A) 현재 설계 유지 (B) productId를 참조용으로 보관 (FK 아닌 논리적 참조). +- **OrderItem-Product 논리 참조**: `productId`는 FK 없이 보관하므로 삭제된 상품에 대해 조인 무결성은 강제되지 않는다. 조회/리포트 로직은 `productId` 미해결 케이스를 허용하도록 설계해야 한다. +- **쿠폰 단일 사용 경쟁 조건**: ISSUED_COUPON 상태 전이(AVAILABLE->USED)는 동시 요청에서 경쟁이 발생할 수 있다. 상태 조건 업데이트 + 제약조건으로 보장해야 한다. +- **만료 판정 일관성**: ERD는 B안(시간 기반)이라 `EXPIRED` 상태를 저장하지 않는다. 조회 계층에서 `now > expiredAt && status=AVAILABLE`이면 EXPIRED로 해석한다. diff --git a/docs/sql/schema-uuid-binary16.sql b/docs/sql/schema-uuid-binary16.sql new file mode 100644 index 000000000..2bc82f626 --- /dev/null +++ b/docs/sql/schema-uuid-binary16.sql @@ -0,0 +1,97 @@ +CREATE TABLE IF NOT EXISTS brands ( + id BINARY(16) NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + image_url VARCHAR(255), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + UNIQUE KEY uk_brands_name (name) +); + +CREATE TABLE IF NOT EXISTS categories ( + id BINARY(16) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + UNIQUE KEY uk_categories_name (name) +); + +CREATE TABLE IF NOT EXISTS members ( + id BINARY(16) NOT NULL, + member_id VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + birth_date DATE NOT NULL, + phone VARCHAR(255), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + UNIQUE KEY uk_members_member_id (member_id) +); + +CREATE TABLE IF NOT EXISTS products ( + id BINARY(16) NOT NULL, + name VARCHAR(255) NOT NULL, + price INT NOT NULL, + stock INT NOT NULL, + description VARCHAR(255), + category_id BINARY(16) NOT NULL, + brand_id BINARY(16) NOT NULL, + like_count INT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + KEY idx_products_category_id (category_id), + KEY idx_products_brand_id (brand_id), + CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id), + CONSTRAINT fk_products_brand FOREIGN KEY (brand_id) REFERENCES brands (id) +); + +CREATE TABLE IF NOT EXISTS orders ( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + order_number VARCHAR(255) NOT NULL, + order_date DATETIME(6) NOT NULL, + status VARCHAR(255) NOT NULL, + total_amount INT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + UNIQUE KEY uk_orders_order_number (order_number), + KEY idx_orders_user_id (user_id), + CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES members (id) +); + +CREATE TABLE IF NOT EXISTS order_items ( + id BINARY(16) NOT NULL, + order_id BINARY(16) NOT NULL, + product_id BINARY(16) NOT NULL, + quantity INT NOT NULL, + snapshot_product_name VARCHAR(255) NOT NULL, + snapshot_price INT NOT NULL, + snapshot_brand_name VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + KEY idx_order_items_order_id (order_id), + KEY idx_order_items_product_id (product_id), + CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders (id) +); + +CREATE TABLE IF NOT EXISTS likes ( + id BINARY(16) NOT NULL, + member_id VARCHAR(255) NOT NULL, + product_id BINARY(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + PRIMARY KEY (id), + UNIQUE KEY uk_likes_member_product (member_id, product_id), + KEY idx_likes_product_id (product_id) +); diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts index e62a6a7ed..8e9d3e105 100644 --- a/modules/jpa/build.gradle.kts +++ b/modules/jpa/build.gradle.kts @@ -18,4 +18,6 @@ dependencies { testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") testFixturesImplementation("org.testcontainers:mysql") + testFixturesImplementation("org.testcontainers:junit-jupiter") + testFixturesImplementation("org.springframework:spring-test") } diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java index d15a9c764..73d758ad1 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -9,6 +9,7 @@ import jakarta.persistence.PreUpdate; import lombok.Getter; import java.time.ZonedDateTime; +import java.util.UUID; /** * 생성/수정/삭제 정보를 자동으로 관리해준다. @@ -19,8 +20,9 @@ public abstract class BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private final Long id = 0L; + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "BINARY(16)", nullable = false, updatable = false) + private UUID id; @Column(name = "created_at", nullable = false, updatable = false) private ZonedDateTime createdAt; diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..c87d7ff16 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -8,6 +8,7 @@ spring: properties: hibernate: default_batch_fetch_size: 100 + type.preferred_uuid_jdbc_type: BINARY timezone.default_storage: NORMALIZE_UTC jdbc.time_zone: UTC diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc..381daa484 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -1,36 +1,33 @@ package com.loopers.testcontainers; -import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; import org.testcontainers.utility.DockerImageName; -@Configuration -public class MySqlTestContainersConfig { +public final class MySqlTestContainersConfig { - private static final MySQLContainer mySqlContainer; - - static { - mySqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) + @Container + @SuppressWarnings("resource") + public static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) .withDatabaseName("loopers") .withUsername("test") .withPassword("test") .withExposedPorts(3306) .withCommand( - "--character-set-server=utf8mb4", - "--collation-server=utf8mb4_general_ci", - "--skip-character-set-client-handshake" + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci", + "--skip-character-set-client-handshake" ); - mySqlContainer.start(); - String mySqlJdbcUrl = String.format( - "jdbc:mysql://%s:%d/%s", - mySqlContainer.getHost(), - mySqlContainer.getFirstMappedPort(), - mySqlContainer.getDatabaseName() - ); + @DynamicPropertySource + public static void overrideDatasourceProperties(DynamicPropertyRegistry registry) { + registry.add("datasource.mysql-jpa.main.jdbc-url", MY_SQL_CONTAINER::getJdbcUrl); + registry.add("datasource.mysql-jpa.main.username", MY_SQL_CONTAINER::getUsername); + registry.add("datasource.mysql-jpa.main.password", MY_SQL_CONTAINER::getPassword); + } - System.setProperty("datasource.mysql-jpa.main.jdbc-url", mySqlJdbcUrl); - System.setProperty("datasource.mysql-jpa.main.username", mySqlContainer.getUsername()); - System.setProperty("datasource.mysql-jpa.main.password", mySqlContainer.getPassword()); + private MySqlTestContainersConfig() { } } diff --git a/modules/redis/build.gradle.kts b/modules/redis/build.gradle.kts index 37ad4f6dd..4e36b0134 100644 --- a/modules/redis/build.gradle.kts +++ b/modules/redis/build.gradle.kts @@ -7,4 +7,6 @@ dependencies { api("org.springframework.boot:spring-boot-starter-data-redis") testFixturesImplementation("com.redis:testcontainers-redis") + testFixturesImplementation("org.testcontainers:junit-jupiter") + testFixturesImplementation("org.springframework:spring-test") } diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f06..857eaddce 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -1,22 +1,25 @@ package com.loopers.testcontainers; import com.redis.testcontainers.RedisContainer; -import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -@Configuration -public class RedisTestContainersConfig { - private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest")); +@Testcontainers +public abstract class RedisTestContainersConfig { - static { - redisContainer.start(); - } + @Container + @SuppressWarnings("resource") + private static final RedisContainer REDIS_CONTAINER = new RedisContainer(DockerImageName.parse("redis:latest")); - public RedisTestContainersConfig() { - System.setProperty("datasource.redis.database", "0"); - System.setProperty("datasource.redis.master.host", redisContainer.getHost()); - System.setProperty("datasource.redis.master.port", String.valueOf(redisContainer.getFirstMappedPort())); - System.setProperty("datasource.redis.replicas[0].host", redisContainer.getHost()); - System.setProperty("datasource.redis.replicas[0].port", String.valueOf(redisContainer.getFirstMappedPort())); + @DynamicPropertySource + static void overrideRedisProperties(DynamicPropertyRegistry registry) { + registry.add("datasource.redis.database", () -> "0"); + registry.add("datasource.redis.master.host", REDIS_CONTAINER::getHost); + registry.add("datasource.redis.master.port", REDIS_CONTAINER::getRedisPort); + registry.add("datasource.redis.replicas[0].host", REDIS_CONTAINER::getHost); + registry.add("datasource.redis.replicas[0].port", REDIS_CONTAINER::getRedisPort); } }