diff --git a/CLAUDE.md b/CLAUDE.md index 75c681157..108d97326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,14 +58,18 @@ infrastructure/ → JpaRepository 구현체 support/error/ → CoreException, ErrorType (에러 코드 enum) ``` -- Repository 패턴: domain에 인터페이스, infrastructure에 구현체 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. + - Repository 패턴: domain에 인터페이스, infrastructure에 구현체 +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성 - Facade 패턴: application 레이어에서 여러 도메인 서비스 조합 - API 버전닝: `/api/v1/` 경로 기반 - 글로벌 예외 처리: `ApiControllerAdvice`에서 `CoreException` → `ApiResponse` 변환 -### BaseEntity (modules/jpa) - -모든 엔티티의 부모 클래스. Auto-increment ID, `createdAt`/`updatedAt`/`deletedAt` 자동 관리, `delete()`/`restore()` 소프트 삭제 지원. +#### 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. ## Testing @@ -118,3 +122,5 @@ support/error/ → CoreException, ErrorType (에러 코드 enum) - 불필요한 코드 제거 및 품질 개선 - 객체지향적 코드 작성, 성능 최적화 - 모든 테스트 케이스가 통과해야 함 + + 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 new file mode 100644 index 000000000..cf2d6331a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,26 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + public void toggleLike(Long productId, Long userId) { + if (likeService.isLiked(productId, userId)) { + likeService.unlike(productId, userId); + productService.decreaseLikeCount(productId); + } else { + likeService.like(productId, userId); + productService.increaseLikeCount(productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java new file mode 100644 index 000000000..5fd34b714 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.order; + +import java.util.Map; + +public record OrderCommand( + Long userId, + Map productQuantities +) { +} 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 new file mode 100644 index 000000000..749a9f9aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItemSpec; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class OrderFacade { + + private final ProductService productService; + private final OrderService orderService; + + public OrderInfo order(OrderCommand command) { + List productIds = new ArrayList<>(command.productQuantities().keySet()); + List products = productService.getByIds(productIds); + + products.forEach(product -> + productService.decreaseStock(product.getId(), command.productQuantities().get(product.getId()))); + + List itemSpecs = products.stream() + .map(product -> new OrderItemSpec( + product.getId(), + product.getPrice(), + command.productQuantities().get(product.getId()) + )) + .toList(); + + // 도메인 서비스에 주문 애그리거트 생성/저장 위임 + Order savedOrder = orderService.placeOrder(command.userId(), itemSpecs); + + return OrderInfo.from(savedOrder); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..0bc35b220 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import java.time.ZonedDateTime; + +public record OrderInfo( + Long id, + Long userId, + OrderStatus status, + Integer totalPrice, + ZonedDateTime orderDt +) { + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getRefUserId(), + order.getStatus(), + order.getTotalPrice().value(), + order.getOrderDt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java new file mode 100644 index 000000000..d56b60288 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.product; + +import java.util.Map; + +public record CreateProductCommand( + Map products +) { + public record ProductItem( + String name, + Integer price, + Integer stock + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..ec392a2fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,72 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.CreateProductRequest; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductService; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ProductFacade { + + private final BrandService brandService; + private final ProductService productService; + + public List createProducts(CreateProductCommand command) { + command.products().keySet().forEach(brandService::getById); + + Map domainRequest = new HashMap<>(); + command.products().forEach((brandId, item) -> { + domainRequest.put(brandId, new CreateProductRequest( + item.name(), + item.price(), + item.stock() + )); + }); + + List products = productService.createProducts(domainRequest); + return products.stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); + } + + @Transactional(readOnly = true) + public ProductInfo getProduct(Long productId) { + Product product = productService.getById(productId); + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + } + + @Transactional(readOnly = true) + public List getProducts(ProductSearchCommand command) { + if (command.hasBrandId()) { + brandService.getById(command.brandId()); + } + + ProductSearchCondition condition = new ProductSearchCondition( + command.brandId(), + command.sortType(), + command.page(), + command.size() + ); + + return productService.findProducts(condition).stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..7e8894b07 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount, + Long brandId, + String brandName, + String brandDescription +) { + + public static ProductInfo of(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getPrice().value(), + product.getStock(), + product.getLikeCount(), + brand.getId(), + brand.getName(), + brand.getDescription() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java new file mode 100644 index 000000000..4687b2f81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductSortType; + +public record ProductSearchCommand( + Long brandId, + ProductSortType sortType, + int page, + int size +) { + public boolean hasBrandId() { + return brandId != null; + } +} 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 new file mode 100644 index 000000000..e33c33db0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,52 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * 브랜드 도메인 객체 + */ +public class Brand { + + private final Long id; + private String name; + private String description; + + private Brand(Long id, String name, String description) { + this.id = id; + this.name = name; + this.description = description; + } + + public static Brand create(Long id, String name, String description) { + validateName(name); + + return new Brand(id, name, description); + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Brand.BRAND_NAME_REQUIRED); + } + } + + public void update(String name, String description) { + validateName(name); + + this.name = name; + this.description = description; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..c82cecbfe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +public interface BrandRepository { + + Brand create(Brand brand); + Brand update(Brand brand); + Optional findById(Long id); + boolean existsById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..7e2c37bc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + public Brand create(String name, String description) { + Brand brand = Brand.create(null, name, description); + return brandRepository.create(brand); + } + + public Brand update(Long id, String name, String description) { + Brand brand = getById(id); + brand.update(name, description); + return brandRepository.update(brand); + } + + public Brand getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Brand.BRAND_NOT_FOUND)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..7b87972d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,27 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * 금액 Value Object + */ +public record Money(Integer value) { + + public static final Money ZERO = new Money(0); + + public Money { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Money.AMOUNT_INVALID); + } + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money multiply(int multiplier) { + return new Money(this.value * multiplier); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8a..3401028b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -2,6 +2,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -17,10 +18,10 @@ protected ExampleModel() {} public ExampleModel(String name, String description) { if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.NAME_REQUIRED); } if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.DESCRIPTION_REQUIRED); } this.name = name; @@ -37,7 +38,7 @@ public String getDescription() { public void update(String newDescription) { if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.DESCRIPTION_REQUIRED); } this.description = newDescription; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java index c0e8431e8..9a634ac25 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -1,6 +1,7 @@ package com.loopers.domain.example; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,6 +16,6 @@ public class ExampleService { @Transactional(readOnly = true) public ExampleModel getExample(Long id) { return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Example.EXAMPLE_NOT_FOUND)); } } 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 new file mode 100644 index 000000000..cf7acc286 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,46 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * 좋아요 도메인 객체 + */ +public class Like { + + private Long id; + private Long refProductId; + private Long refUserId; + + private Like(Long id, Long refProductId, Long refUserId) { + this.id = id; + this.refProductId = refProductId; + this.refUserId = refUserId; + } + + public static Like create(Long id, Long refProductId, Long refUserId) { + validateRefId(refProductId, refUserId); + + return new Like(id, refProductId, refUserId); + } + + private static void validateRefId(Long refProductId, Long refUserId) { + if (refProductId == null || refProductId < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.PRODUCT_ID_INVALID); + } + + if (refUserId == null || refUserId < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.USER_ID_INVALID); + } + } + + public Long getRefProductId() { + return refProductId; + } + + public Long getRefUserId() { + return refUserId; + } + +} 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 new file mode 100644 index 000000000..dfc960690 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + boolean existByUniqueId(Long productId, Long userId); + + Optional findByUniqueId(Long productId, Long userId); + + Like save(Like like); + + void delete(Like like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..58d72adc8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,39 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + + public Like like(Long productId, Long userId) { + if (likeRepository.existByUniqueId(productId, userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.ALREADY_LIKED); + } + + Like like = Like.create(null, productId, userId); + + return likeRepository.save(like); + } + + public void unlike(Long productId, Long userId) { + Like like = findByUniqueId(productId, userId); + + likeRepository.delete(like); + } + + public boolean isLiked(Long productId, Long userId) { + return likeRepository.existByUniqueId(productId, userId); + } + + public Like findByUniqueId(Long productId, Long userId) { + return likeRepository.findByUniqueId(productId, userId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.LIKE_NOT_FOUND)); + } +} 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 new file mode 100644 index 000000000..a9656b7a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,175 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Order 도메인 (애그리거트 루트) + */ +public class Order { + + private final Long id; + private final Long refUserId; + + private OrderStatus status; + private Money totalPrice; + private ZonedDateTime orderDt; + + // 애그리거트 내부 엔티티 컬렉션 + private final List items = new ArrayList<>(); + private final List histories = new ArrayList<>(); + + private Order(Long id, Long refUserId, OrderStatus status, Money totalPrice, ZonedDateTime orderDt) { + this.id = id; + this.refUserId = refUserId; + this.status = status; + this.totalPrice = totalPrice; + this.orderDt = orderDt; + } + + public static Order create(Long id, Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + validateRefUserId(refUserId); + validateOrderDt(orderDt); + + return new Order(id, refUserId, status, new Money(totalPrice), orderDt); + } + + /** + * 저장소에서 복원할 때 사용하는 팩토리 메서드 + * items와 histories를 함께 받아서 복원한다. + */ + public static Order restore(Long id, Long refUserId, OrderStatus status, Integer totalPrice, + ZonedDateTime orderDt, List items, List histories) { + validateRefUserId(refUserId); + validateOrderDt(orderDt); + + Order order = new Order(id, refUserId, status, new Money(totalPrice), orderDt); + if (items != null) { + order.items.addAll(items); + } + if (histories != null) { + order.histories.addAll(histories); + } + return order; + } + + /** + * 주문 애그리거트 생성 팩토리 + * - 주문자, 주문 아이템 스펙, 주문 시각을 받아 애그리거트를 구성한다. + * - 현재 단계에서는 OrderItem / OrderStatusHistory 컬렉션을 조립하는 책임만 추가한다. + */ + public static Order place(Long userId, List itemSpecs, ZonedDateTime now) { + validateRefUserId(userId); + if (itemSpecs == null || itemSpecs.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ITEMS_EMPTY); + } + validateOrderDt(now); + + Money total = itemSpecs.stream() + .map(spec -> spec.price().multiply(spec.quantity())) + .reduce(Money.ZERO, Money::add); + + Order order = new Order( + null, + userId, + OrderStatus.ORDERED, + total, + now + ); + + itemSpecs.forEach(spec -> + order.addItem(spec.productId(), spec.price(), spec.quantity()) + ); + order.recordStatusChange(OrderStatus.ORDERED, now); + + return order; + } + + private static void validateRefUserId(Long refUserId) { + if (refUserId == null || refUserId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.USER_ID_INVALID); + } + } + + private static void validateOrderDt(ZonedDateTime orderDt) { + if (orderDt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_DT_REQUIRED); + } + } + + /** + * 애그리거트 내부에 주문 아이템 추가 + */ + public void addItem(Long productId, Money unitPrice, int quantity) { + this.items.add(OrderItem.create( + null, + this.id, // 새 주문의 경우 null일 수 있지만, 인프라에서 매핑 시 채워진다 + productId, + quantity, + unitPrice.value() + )); + } + + /** + * 주문 상태 변경 + 이력 기록 + */ + public void recordStatusChange(OrderStatus newStatus, ZonedDateTime changedAt) { + if (newStatus == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_REQUIRED); + } + if (changedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_CHANGE_DT_REQUIRED); + } + this.status = newStatus; + this.histories.add(OrderStatusHistory.create( + null, + this.id, + newStatus, + changedAt + )); + } + + public void cancel() { + if (this.status != OrderStatus.ORDERED) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.CANCEL_ONLY_WHEN_COMPLETED); + } + this.status = OrderStatus.CANCELLED; + } + + public Long getId() { + return id; + } + + public Long getRefUserId() { + return refUserId; + } + + public OrderStatus getStatus() { + return status; + } + + public Money getTotalPrice() { + return totalPrice; + } + + public ZonedDateTime getOrderDt() { + return orderDt; + } + + /** + * 애그리거트 내부 컬렉션 조회용 (불변 뷰 반환) + */ + public List getItems() { + return List.copyOf(items); + } + + public List getHistories() { + return List.copyOf(histories); + } +} + 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 new file mode 100644 index 000000000..736c51277 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,40 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * OrderItem 도메인 + */ +public record OrderItem(Long id, Long refOrderId, Long refProductId, Integer quantity, Money price) { + + public static OrderItem create(Long id, Long refOrderId, Long refProductId, Integer quantity, Integer price) { + validateRefOrderId(refOrderId); + validateRefProductId(refProductId); + validateQuantity(quantity); + + return new OrderItem(id, refOrderId, refProductId, quantity, new Money(price)); + } + + private static void validateRefOrderId(Long refOrderId) { + // 새 Order 애그리거트 구성 시점에는 refOrderId가 아직 없을 수 있으므로 + // null 은 허용하고, 0 이하인 값만 검증 대상으로 본다. + if (refOrderId != null && refOrderId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); + } + } + + private static void validateRefProductId(Long refProductId) { + if (refProductId == null || refProductId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.PRODUCT_ID_INVALID); + } + } + + private static void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.QUANTITY_MUST_BE_POSITIVE); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..6de57cb2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List saveAll(List orderItems); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java new file mode 100644 index 000000000..7f19fa7b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderItemService { + + private final OrderItemRepository orderItemRepository; + + public void createOrderItems(Long orderId, List products, Map productQuantities) { + if (products == null || products.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ITEMS_EMPTY); + } + if (productQuantities == null || productQuantities.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_QUANTITIES_EMPTY); + } + + List orderItems = products.stream() + .map(product -> OrderItem.create( + null, + orderId, + product.getId(), + productQuantities.get(product.getId()), + product.getPrice().value() + )) + .toList(); + + orderItemRepository.saveAll(orderItems); + } + + public Money calculateTotalPrice(List products, Map productQuantities) { + return products.stream() + .map(product -> product.getPrice().multiply(productQuantities.get(product.getId()))) + .reduce(Money.ZERO, Money::add); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java new file mode 100644 index 000000000..97cf0a34f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java @@ -0,0 +1,14 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; + +/** + * 주문 시 사용되는 주문 상품 스펙 값 객체 + * - 애그리거트 외부(Application 계층 등)에서 Order 생성 시 사용 + */ +public record OrderItemSpec( + Long productId, + Money price, + int quantity +) { +} 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 new file mode 100644 index 000000000..5a83a7f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..0fac4eff9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,36 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + /** + * 기존 단순 생성용 메서드 (다른 곳에서 사용 중일 수 있어 유지) + */ + public Order createOrder(Long refUserId, Integer totalPrice) { + Order order = Order.create( + null, + refUserId, + OrderStatus.ORDERED, + totalPrice, + ZonedDateTime.now() + ); + return orderRepository.save(order); + } + + /** + * 애그리거트 기준 주문 생성 + * - Order.place(...)를 호출해 애그리거트를 만들고, OrderRepository를 통해 저장한다. + */ + public Order placeOrder(Long userId, List itemSpecs) { + Order order = Order.place(userId, itemSpecs, ZonedDateTime.now()); + return orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..f1f7a0778 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + + ORDERED("주문 완료"), // 주문 완료 + CANCELLED("주문 취소"); // 주문 취소 + + private final String message; + + OrderStatus(String message) { + this.message = message; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java new file mode 100644 index 000000000..709101f75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java @@ -0,0 +1,40 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import java.time.ZonedDateTime; + +/** + * OrderStatusHistory 도메인 + */ +public record OrderStatusHistory(Long id, Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { + + public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { + validateRefOrderId(refOrderId); + validateStatus(status); + validateChangedAt(changedAt); + + return new OrderStatusHistory(id, refOrderId, status, changedAt); + } + + private static void validateRefOrderId(Long refOrderId) { + // 새 Order 애그리거트 구성 시점에는 refOrderId가 아직 없을 수 있으므로 + // null 은 허용하고, 0 이하인 값만 검증 대상으로 본다. + if (refOrderId != null && refOrderId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); + } + } + + private static void validateStatus(OrderStatus status) { + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_REQUIRED); + } + } + + private static void validateChangedAt(ZonedDateTime changedAt) { + if (changedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_CHANGE_DT_REQUIRED); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java new file mode 100644 index 000000000..e2b253732 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public interface OrderStatusHistoryRepository { + + OrderStatusHistory save(OrderStatusHistory history); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java new file mode 100644 index 000000000..2794838cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderStatusHistoryService { + + private final OrderStatusHistoryRepository orderStatusHistoryRepository; + + public void recordHistory(Long orderId, OrderStatus status) { + OrderStatusHistory history = OrderStatusHistory.create( + null, + orderId, + status, + ZonedDateTime.now() + ); + orderStatusHistoryRepository.save(history); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java new file mode 100644 index 000000000..bbfbbcf10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +public record CreateProductRequest( + String name, + Integer price, + Integer stock +) { +} 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 new file mode 100644 index 000000000..4c9313b45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,113 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * Product 도메인 + */ +public class Product { + + private final Long id; + private final Long refBrandId; + + private String name; + private Money price; + private Integer stock; + private Integer likeCount; + + private Product(Long id, String name, Long refBrandId, Money price, Integer stock, Integer likeCount) { + this.id = id; + this.name = name; + this.refBrandId = refBrandId; + this.price = price; + this.stock = stock; + this.likeCount = likeCount; + } + + public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { + validateName(name); + validateBrandId(refBrandId); + validateStock(stock); + validateLike(likeCount); + + return new Product(id, name, refBrandId, new Money(price), stock, likeCount); + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_NAME_REQUIRED); + } + } + + private static void validateBrandId(Long refBrand) { + if (refBrand == null || refBrand <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.BRAND_ID_INVALID); + } + } + + private static void validateStock(Integer stock) { + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.STOCK_INVALID); + } + } + + private static void validateLike(Integer likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.LIKE_COUNT_INVALID); + } + } + + public boolean hasEnoughStock(Integer requiredQuantity) { + return this.stock >= requiredQuantity; + } + + public void decreaseStock(Integer quantity) { + validateQuantity(quantity); + if (!hasEnoughStock(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.INSUFFICIENT_STOCK); + } + this.stock -= quantity; + } + + public void increaseLikeCount() { + this.likeCount += 1; + } + + public void decreaseLikeCount() { + if(this.likeCount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.LIKE_COUNT_NEGATIVE); + } + this.likeCount -= 1; + } + + private void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.QUANTITY_MUST_BE_POSITIVE); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getRefBrandId() { + return refBrandId; + } + + public Money getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public Integer getLikeCount() { return likeCount; } +} 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 new file mode 100644 index 000000000..77a427cc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + List findByIds(List ids); + + Product update(Product product); + + List findAll(ProductSearchCondition condition); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java new file mode 100644 index 000000000..8e2ae6c15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +public record ProductSearchCondition( + Long brandId, + ProductSortType sortType, + int page, + int size +) { + public static ProductSearchCondition of(Long brandId, ProductSortType sortType, int page, int size) { + return new ProductSearchCondition(brandId, sortType, page, size); + } + + public boolean hasBrandId() { + return brandId != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..22fece86e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,81 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + public List createProducts(Map createProductsCommand) { + if (createProductsCommand == null || createProductsCommand.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.CREATE_PRODUCT_REQUEST_REQUIRED); + } + + List createdProducts = new ArrayList<>(); + + createProductsCommand.forEach((brandId, request) -> { + Product product = Product.create( + null, + request.name(), + brandId, + request.price(), + request.stock(), + 0 + ); + Product savedProduct = productRepository.save(product); + createdProducts.add(savedProduct); + }); + + return createdProducts; + } + + public void increaseLikeCount(Long productId) { + Product product = getById(productId); + product.increaseLikeCount(); + productRepository.update(product); + } + + public void decreaseLikeCount(Long productId) { + Product product = getById(productId); + product.decreaseLikeCount(); + productRepository.update(product); + } + + public void decreaseStock(Long productId, Integer decreaseStock) { + Product product = getById(productId); + product.decreaseStock(decreaseStock); + productRepository.update(product); + } + + public Product getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_NOT_FOUND)); + } + + public List getByIds(List ids) { + if (ids == null || ids.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_ID_LIST_REQUIRED); + } + List products = productRepository.findByIds(ids); + if (products.size() != ids.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_ID_LIST_CONTAINS_INVALID); + } + return products; + } + + public List findProducts(ProductSearchCondition condition) { + if (condition == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.SEARCH_CONDITION_REQUIRED); + } + return productRepository.findAll(condition); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..721974520 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index b19ca04bf..016410276 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -2,6 +2,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -54,19 +55,19 @@ private UserModel(String loginId, String password, LocalDate birthDate, String n @Override protected void guard() { if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.LOGIN_ID_REQUIRED); } if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.PASSWORD_REQUIRED); } if (birthDate == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.BIRTH_DATE_REQUIRED); } if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.NAME_REQUIRED); } if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.EMAIL_REQUIRED); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 7f65e7722..44cf6fafc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,6 +1,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; import java.time.LocalDate; @@ -18,10 +19,10 @@ public class UserService { @Transactional public UserModel createUser(String loginId, String rawPassword, LocalDate birthDate, String name, String email) { if (userRepository.existsByLoginId(loginId)) { - throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용 중인 아이디입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.LOGIN_ID_ALREADY_EXISTS); } if (userRepository.existsByEmail(email)) { - throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 이메일입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.EMAIL_ALREADY_EXISTS); } validatePasswordNotContainsBirthDate(rawPassword, birthDate); @@ -32,9 +33,9 @@ public UserModel createUser(String loginId, String rawPassword, LocalDate birthD public UserModel authenticate(String loginId, String rawPassword) { UserModel user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.User.INVALID_LOGIN_INFO)); if (!passwordEncoder.matches(rawPassword, user.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다."); + throw new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.User.INVALID_LOGIN_INFO); } return user; } @@ -45,7 +46,7 @@ public Boolean existsByEmail(String email) { public UserModel findById(Long id) { return userRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.User.USER_NOT_FOUND)); } @Transactional @@ -53,11 +54,11 @@ public void changePassword(Long userId, String currentPassword, String newPasswo UserModel user = findById(userId); if (!passwordEncoder.matches(currentPassword, user.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.CURRENT_PASSWORD_MISMATCH); } if (passwordEncoder.matches(newPassword, user.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.NEW_PASSWORD_SAME_AS_CURRENT); } validatePasswordNotContainsBirthDate(newPassword, user.getBirthDate()); @@ -69,7 +70,7 @@ public void changePassword(Long userId, String currentPassword, String newPasswo private void validatePasswordNotContainsBirthDate(String password, LocalDate birthDate) { String birthStr = birthDate.toString().replace("-", ""); if (password.contains(birthStr)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..180b31d2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * 브랜드 DB 엔티티 + */ +@Entity +@Table(name = "brand") +@NoArgsConstructor +public class BrandEntity extends BaseEntity { + + @Comment("브랜드 이름") + @Column(name = "name", nullable = false) + private String name; + + @Comment("브랜드 설명") + @Column(name = "description", nullable = true) + private String description; + + private BrandEntity(String name, String description) { + this.name = name; + this.description = description; + } + + public static BrandEntity create(Brand brand) { + return new BrandEntity( + brand.getName(), + brand.getDescription() + ); + } + + public static Brand toDomain(BrandEntity brandEntity) { + return Brand.create(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription()); + } + + public void update(Brand brand) { + this.name = brand.getName(); + this.description = brand.getDescription(); + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } +} 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 new file mode 100644 index 000000000..566223c34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.brand; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + +} 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 new file mode 100644 index 000000000..965782be4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,44 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand create(Brand brand) { + BrandEntity brandEntity = BrandEntity.create(brand); + + return BrandEntity.toDomain(brandJpaRepository.save(brandEntity)); + } + + @Override + public Brand update(Brand brand) { + BrandEntity brandEntity = brandJpaRepository.findById(brand.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Brand.BRAND_NOT_FOUND)); + brandEntity.update(brand); + + return BrandEntity.toDomain(brandJpaRepository.save(brandEntity)); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .map(BrandEntity::toDomain); + } + + @Override + public boolean existsById(Long id) { + return brandJpaRepository.existsById(id); + } +} 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 new file mode 100644 index 000000000..da0dd41b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.like.Like; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * 좋아요 DB 엔티티 + */ +@Entity +@Table(name = "likes") +@NoArgsConstructor +public class LikeEntity extends BaseEntity { + + @Comment("상품 id (ref)") + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long refProductId; + + @Comment("유저 id (ref)") + @Column(name = "ref_user_id", nullable = false, updatable = false) + private Long refUserId; + + public LikeEntity(Like like) { + this.refProductId = like.getRefProductId(); + this.refUserId = like.getRefUserId(); + } + + public static LikeEntity toEntity(Like like) { + return new LikeEntity(like); + } + + public Long getRefProductId() { + return refProductId; + } + + public Long getRefUserId() { + return refUserId; + } + + public static Like toDomain(LikeEntity likeEntity) { + return Like.create( + likeEntity.getId(), + likeEntity.getRefProductId(), + likeEntity.getRefUserId() + ); + } +} 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 new file mode 100644 index 000000000..7de3c68fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.like; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByRefProductIdAndRefUserId(Long productId, Long userId); + + Optional findByRefProductIdAndRefUserId(Long productId, Long userId); +} 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 new file mode 100644 index 000000000..aae1d8bc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + LikeEntity likeEntity = LikeEntity.toEntity(like); + + return LikeEntity.toDomain(likeJpaRepository.save(likeEntity)); + } + + @Override + public void delete(Like like) { + likeJpaRepository.findByRefProductIdAndRefUserId(like.getRefProductId(), like.getRefUserId()) + .ifPresent(likeJpaRepository::delete); + } + + @Override + public boolean existByUniqueId(Long productId, Long userId) { + return likeJpaRepository.existsByRefProductIdAndRefUserId(productId, userId); + } + + @Override + public Optional findByUniqueId(Long productId, Long userId) { + return likeJpaRepository.findByRefProductIdAndRefUserId(productId, userId) + .map(LikeEntity::toDomain); + } +} 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 new file mode 100644 index 000000000..ab53580d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,122 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderStatusHistory; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * Order DB 엔티티 + */ +@Entity +@Table(name = "orders") +@NoArgsConstructor +public class OrderEntity extends BaseEntity { + + @Comment("유저 id (ref)") + @Column(name = "ref_user_id", nullable = false, updatable = false) + private Long refUserId; + + @Comment("주문 상태") + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Comment("총 주문 금액") + @Column(name = "total_price", nullable = false) + private Integer totalPrice; + + @Comment("주문 일시") + @Column(name = "order_dt", nullable = false, updatable = false) + private ZonedDateTime orderDt; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "ref_order_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private List items = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "ref_order_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private List histories = new ArrayList<>(); + + private OrderEntity(Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + this.refUserId = refUserId; + this.status = status; + this.totalPrice = totalPrice; + this.orderDt = orderDt; + } + + public static OrderEntity create(Order order) { + OrderEntity entity = new OrderEntity( + order.getRefUserId(), + order.getStatus(), + order.getTotalPrice().value(), + order.getOrderDt() + ); + + order.getItems().forEach(item -> + entity.items.add(OrderItemEntity.create(item)) + ); + + order.getHistories().forEach(history -> + entity.histories.add(OrderStatusHistoryEntity.create(history)) + ); + + return entity; + } + + public Order toDomain() { + List domainItems = items.stream() + .map(OrderItemEntity::toDomain) + .toList(); + + List domainHistories = histories.stream() + .map(OrderStatusHistoryEntity::toDomain) + .toList(); + + return Order.restore( + this.getId(), + this.refUserId, + this.status, + this.totalPrice, + this.orderDt, + domainItems, + domainHistories + ); + } + + public void updateStatus(OrderStatus status) { + this.status = status; + } + + public Long getRefUserId() { + return refUserId; + } + + public OrderStatus getStatus() { + return status; + } + + public Integer getTotalPrice() { + return totalPrice; + } + + public ZonedDateTime getOrderDt() { + return orderDt; + } +} 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 new file mode 100644 index 000000000..fcd9d95d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.OrderItem; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * OrderItem DB 엔티티 + */ +@Entity +@Table(name = "order_item") +@NoArgsConstructor +public class OrderItemEntity extends BaseEntity { + + @Comment("주문 id (ref)") + @Column(name = "ref_order_id", insertable = false, updatable = false) + private Long refOrderId; + + @Comment("상품 id (ref)") + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long refProductId; + + @Comment("주문 수량") + @Column(name = "quantity", nullable = false, updatable = false) + private Integer quantity; + + @Comment("주문 금액") + @Column(name = "price", nullable = false, updatable = false) + private Integer price; + + private OrderItemEntity(Long refOrderId, Long refProductId, Integer quantity, Integer price) { + this.refOrderId = refOrderId; + this.refProductId = refProductId; + this.quantity = quantity; + this.price = price; + } + + public static OrderItemEntity create(OrderItem orderItem) { + return new OrderItemEntity( + orderItem.refOrderId(), + orderItem.refProductId(), + orderItem.quantity(), + orderItem.price().value() + ); + } + + public OrderItem toDomain() { + return OrderItem.create( + this.getId(), + this.refOrderId, + this.refProductId, + this.quantity, + this.price + ); + } + + public Long getRefOrderId() { + return refOrderId; + } + + public Long getRefProductId() { + return refProductId; + } + + public Integer getQuantity() { + return quantity; + } + + public Integer getPrice() { + return price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..45f77468c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.order; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findByRefOrderId(Long refOrderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..cd7dc5078 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List saveAll(List orderItems) { + List entities = orderItems.stream() + .map(OrderItemEntity::create) + .toList(); + + return orderItemJpaRepository.saveAll(entities).stream() + .map(OrderItemEntity::toDomain) + .toList(); + } +} 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 new file mode 100644 index 000000000..116da795b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + +} 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 new file mode 100644 index 000000000..a94076fde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + OrderEntity orderEntity = OrderEntity.create(order); + OrderEntity savedOrderEntity = orderJpaRepository.save(orderEntity); + return savedOrderEntity.toDomain(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java new file mode 100644 index 000000000..c6dfce139 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderStatusHistory; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * OrderStatusHistory DB 엔티티 + */ +@Entity +@Table(name = "order_status_history") +@NoArgsConstructor +public class OrderStatusHistoryEntity extends BaseEntity { + + @Comment("주문 id (ref)") + @Column(name = "ref_order_id", insertable = false, updatable = false) + private Long refOrderId; + + @Comment("주문 상태") + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, updatable = false) + private OrderStatus status; + + @Comment("상태 변경 일시") + @Column(name = "changed_at", nullable = false, updatable = false) + private ZonedDateTime changedAt; + + private OrderStatusHistoryEntity(Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { + this.refOrderId = refOrderId; + this.status = status; + this.changedAt = changedAt; + } + + public static OrderStatusHistoryEntity create(OrderStatusHistory history) { + return new OrderStatusHistoryEntity( + history.refOrderId(), + history.status(), + history.changedAt() + ); + } + + public OrderStatusHistory toDomain() { + return OrderStatusHistory.create( + this.getId(), + this.refOrderId, + this.status, + this.changedAt + ); + } + + public Long getRefOrderId() { + return refOrderId; + } + + public OrderStatus getStatus() { + return status; + } + + public ZonedDateTime getChangedAt() { + return changedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java new file mode 100644 index 000000000..e30b74e98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.order; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderStatusHistoryJpaRepository extends JpaRepository { + + List findByRefOrderId(Long refOrderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java new file mode 100644 index 000000000..da943f192 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderStatusHistory; +import com.loopers.domain.order.OrderStatusHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderStatusHistoryRepositoryImpl implements OrderStatusHistoryRepository { + + private final OrderStatusHistoryJpaRepository orderStatusHistoryJpaRepository; + + @Override + public OrderStatusHistory save(OrderStatusHistory history) { + OrderStatusHistoryEntity entity = OrderStatusHistoryEntity.create(history); + return orderStatusHistoryJpaRepository.save(entity).toDomain(); + } +} 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 new file mode 100644 index 000000000..30bbd0c2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * Product DB 엔티티 + */ +@Entity +@Table(name = "product") +@NoArgsConstructor +public class ProductEntity extends BaseEntity { + + @Comment("상품 이름") + @Column(name = "name", nullable = false) + private String name; + + @Comment("브랜드 id (ref)") + @Column(name = "ref_brand_id", nullable = false, updatable = false) + private Long refBrandId; + + @Comment("현재 판매가") + @Column(name = "price", nullable = false) + private Integer price; + + @Comment("현재 재고") + @Column(name = "stock", nullable = false) + private Integer stock; + + @Comment("좋아요 수") + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + public ProductEntity(Product product) { + this.name = product.getName(); + this.refBrandId = product.getRefBrandId(); + this.price = product.getPrice().value(); + this.likeCount = product.getLikeCount(); + } + + public static ProductEntity create(Product product) { + return new ProductEntity(product); + } + + public static Product toDomain(ProductEntity productEntity) { + return Product.create( + productEntity.getId(), + productEntity.getName(), + productEntity.getRefBrandId(), + productEntity.getPrice(), + productEntity.getStock(), + productEntity.getLikeCount() + ); + } + + public void update(Product product) { + this.name = product.getName(); + this.refBrandId = product.getRefBrandId(); + this.price = product.getPrice().value(); + this.stock = product.getStock(); + } + + public String getName() { + return name; + } + + public Long getRefBrandId() { + return refBrandId; + } + + public Integer getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public Integer getLikeCount() { + return likeCount; + } +} 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 new file mode 100644 index 000000000..f3b314226 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.product; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} 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 new file mode 100644 index 000000000..fb140b0ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.loopers.infrastructure.product; + +import static com.loopers.infrastructure.product.QProductEntity.productEntity; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public Product save(Product product) { + ProductEntity productEntity = ProductEntity.create(product); + + return ProductEntity.toDomain(productJpaRepository.save(productEntity)); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id) + .map(ProductEntity::toDomain); + } + + @Override + public List findByIds(List ids) { + return productJpaRepository.findAllById(ids).stream() + .map(ProductEntity::toDomain) + .toList(); + } + + @Override + public Product update(Product product) { + ProductEntity productEntity = productJpaRepository.findById(product.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Product.PRODUCT_NOT_FOUND)); + productEntity.update(product); + + return ProductEntity.toDomain(productJpaRepository.save(productEntity)); + } + + @Override + public List findAll(ProductSearchCondition condition) { + return queryFactory + .selectFrom(productEntity) + .where( + condition.hasBrandId() ? productEntity.refBrandId.eq(condition.brandId()) : null + ) + .orderBy(getOrderSpecifier(condition.sortType())) + .offset((long) condition.page() * condition.size()) + .limit(condition.size()) + .fetch() + .stream() + .map(ProductEntity::toDomain) + .toList(); + } + + private OrderSpecifier getOrderSpecifier(ProductSortType sortType) { + return switch (sortType) { + case LATEST -> productEntity.createdAt.desc(); + case PRICE_ASC -> productEntity.price.asc(); + case LIKES_DESC -> productEntity.likeCount.desc(); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index e1b9b5949..c8cf65de2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 19a304e68..92e97c259 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java index 61b17d315..3a0ef9704 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java @@ -4,6 +4,7 @@ import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -42,7 +43,7 @@ public Object resolveArgument( String loginPw = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_PW); if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { - throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); + throw new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.Auth.AUTH_HEADER_MISSING); } UserModel user = userService.authenticate(loginId, loginPw); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..92d30d140 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/{productId}/like") + public ApiResponse toggleLike( + @PathVariable Long productId, + @AuthUser AuthUserPrincipal user + ) { + likeFacade.toggleLike(productId, user.getId()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..77edb5bb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping + public ApiResponse createOrder( + @AuthUser AuthUserPrincipal user, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderCommand command = new OrderCommand( + user.getId(), + request.toProductQuantities() + ); + OrderInfo orderInfo = orderFacade.order(command); + return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(orderInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..72537824f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class OrderV1Dto { + + public record CreateOrderRequest( + + @NotEmpty(message = "주문 상품은 필수입니다") + @Valid + List items + ) { + public Map toProductQuantities() { + return items.stream() + .collect(Collectors.toMap( + OrderItemRequest::productId, + OrderItemRequest::quantity + )); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + + @NotNull(message = "수량은 필수입니다") + @Min(value = 1, message = "수량은 1개 이상이어야 합니다") + Integer quantity + ) { + + } + + public record CreateOrderResponse( + Long id, + Long userId, + OrderStatus status, + Integer totalPrice, + ZonedDateTime orderDt + ) { + + public static CreateOrderResponse from(OrderInfo orderInfo) { + return new CreateOrderResponse( + orderInfo.id(), + orderInfo.userId(), + orderInfo.status(), + orderInfo.totalPrice(), + orderInfo.orderDt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..23016637e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.CreateProductCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSearchCommand; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse createProducts( + @Valid @RequestBody ProductV1Dto.CreateProductRequest request + ) { + CreateProductCommand command = new CreateProductCommand( + request.toProductItems() + ); + List productInfos = productFacade.createProducts(command); + return ApiResponse.success(ProductV1Dto.CreateProductResponse.from(productInfos)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @PathVariable Long productId + ) { + ProductInfo info = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSortType sortType, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + ProductSearchCommand command = new ProductSearchCommand( + brandId, + sortType, + page, + size + ); + List productInfos = productFacade.getProducts(command); + List response = productInfos.stream() + .map(ProductV1Dto.ProductResponse::from) + .toList(); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..94ac0f18f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ProductV1Dto { + + public record CreateProductRequest( + @NotEmpty(message = "상품 목록은 필수입니다") + @Valid + List products + ) { + public Map toProductItems() { + return products.stream() + .collect(Collectors.toMap( + ProductItemRequest::brandId, + item -> new com.loopers.application.product.CreateProductCommand.ProductItem( + item.name(), + item.price(), + item.stock() + ) + )); + } + } + + public record ProductItemRequest( + @NotNull(message = "브랜드 ID는 필수입니다") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다") + String name, + + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + Integer price, + + @NotNull(message = "재고는 필수입니다") + @Min(value = 0, message = "재고는 0개 이상이어야 합니다") + Integer stock + ) { + } + + public record CreateProductResponse( + List products + ) { + public static CreateProductResponse from(List productInfos) { + List products = productInfos.stream() + .map(ProductResponse::from) + .toList(); + return new CreateProductResponse(products); + } + } + + public record ProductResponse( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.price(), + info.stock(), + info.likeCount() + ); + } + } + + public record ProductDetailResponse( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount, + Long brandId, + String brandName, + String brandDescription + ) { + public static ProductDetailResponse from(ProductInfo info) { + return new ProductDetailResponse( + info.id(), + info.name(), + info.price(), + info.stock(), + info.likeCount(), + info.brandId(), + info.brandName(), + info.brandDescription() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java new file mode 100644 index 000000000..66b0f2b97 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java @@ -0,0 +1,121 @@ +package com.loopers.support.error; + +/** + * 예외 메시지를 중앙에서 관리하는 클래스 + * 프로덕션 코드와 테스트 코드에서 공통으로 사용 + */ +public final class ErrorMessage { + + private ErrorMessage() { + throw new AssertionError("Utility class should not be instantiated"); + } + + /** + * User 도메인 관련 에러 메시지 + */ + public static final class User { + private User() {} + + public static final String LOGIN_ID_ALREADY_EXISTS = "이미 사용 중인 아이디입니다."; + public static final String EMAIL_ALREADY_EXISTS = "이미 가입된 이메일입니다."; + public static final String INVALID_LOGIN_INFO = "로그인 정보가 올바르지 않습니다."; + public static final String USER_NOT_FOUND = "사용자를 찾을 수 없습니다."; + public static final String CURRENT_PASSWORD_MISMATCH = "기존 비밀번호가 일치하지 않습니다."; + public static final String NEW_PASSWORD_SAME_AS_CURRENT = "새 비밀번호는 기존 비밀번호와 달라야 합니다."; + public static final String PASSWORD_CONTAINS_BIRTH_DATE = "비밀번호에 생년월일을 포함할 수 없습니다."; + public static final String LOGIN_ID_REQUIRED = "로그인 ID는 필수입니다."; + public static final String PASSWORD_REQUIRED = "비밀번호는 필수입니다."; + public static final String BIRTH_DATE_REQUIRED = "생년월일은 필수입니다."; + public static final String NAME_REQUIRED = "이름은 필수입니다."; + public static final String EMAIL_REQUIRED = "이메일은 필수입니다."; + } + + /** + * Money VO 관련 에러 메시지 + */ + public static final class Money { + private Money() {} + + public static final String AMOUNT_INVALID = "금액은 null이거나 음수가 될 수 없습니다"; + } + + /** + * Product 도메인 관련 에러 메시지 + */ + public static final class Product { + private Product() {} + + public static final String PRODUCT_NOT_FOUND = "상품을 찾을 수 없습니다"; + public static final String CREATE_PRODUCT_REQUEST_REQUIRED = "상품 생성 요청은 필수입니다"; + public static final String PRODUCT_ID_LIST_REQUIRED = "상품 ID 목록은 필수입니다"; + public static final String PRODUCT_ID_LIST_CONTAINS_INVALID = "존재하지 않는 상품이 포함되어 있습니다"; + public static final String SEARCH_CONDITION_REQUIRED = "검색 조건은 필수입니다"; + public static final String PRODUCT_NAME_REQUIRED = "상품 이름은 필수 입니다"; + public static final String BRAND_ID_INVALID = "브랜드FK는 null이거나 0이하가 될 수 없습니다"; + public static final String STOCK_INVALID = "상품 재고는 null이거나 음수가 될 수 없습니다"; + public static final String LIKE_COUNT_INVALID = "좋아요 수는 null이거나 음수가 될 수 없습니다"; + public static final String INSUFFICIENT_STOCK = "재고가 부족합니다"; + public static final String LIKE_COUNT_NEGATIVE = "좋아요 갯수는 음수가 될 수 없습니다"; + public static final String QUANTITY_MUST_BE_POSITIVE = "수량은 양수여야 합니다"; + } + + /** + * Brand 도메인 관련 에러 메시지 + */ + public static final class Brand { + private Brand() {} + + public static final String BRAND_NOT_FOUND = "브랜드를 찾을 수 없습니다"; + public static final String BRAND_NAME_REQUIRED = "브랜드 이름은 필수 입니다"; + } + + /** + * Order 도메인 관련 에러 메시지 + */ + public static final class Order { + private Order() {} + + public static final String ORDER_ITEMS_EMPTY = "주문할 상품이 없습니다"; + public static final String ORDER_QUANTITIES_EMPTY = "주문 수량 정보가 없습니다"; + public static final String ORDER_ID_INVALID = "주문FK는 null이거나 0이하가 될 수 없습니다"; + public static final String ORDER_STATUS_REQUIRED = "주문 상태는 필수입니다"; + public static final String ORDER_STATUS_CHANGE_DT_REQUIRED = "주문 상태 변경 일시는 필수입니다"; + public static final String PRODUCT_ID_INVALID = "상품FK는 null이거나 0이하가 될 수 없습니다"; + public static final String USER_ID_INVALID = "유저FK는 null이거나 0이하가 될 수 없습니다"; + public static final String ORDER_DT_REQUIRED = "주문 일시는 필수입니다"; + public static final String CANCEL_ONLY_WHEN_COMPLETED = "주문 완료 상태에서만 취소할 수 있습니다"; + public static final String QUANTITY_MUST_BE_POSITIVE = "수량은 양수여야 합니다"; + } + + /** + * Like 도메인 관련 에러 메시지 + */ + public static final class Like { + private Like() {} + + public static final String ALREADY_LIKED = "이미 좋아요를 누른 상품입니다"; + public static final String LIKE_NOT_FOUND = "좋아요 객체를 찾을 수 없습니다"; + public static final String PRODUCT_ID_INVALID = "상품FK는 null이거나 음수가 될 수 없습니다"; + public static final String USER_ID_INVALID = "유저FK는 null이거나 음수가 될 수 없습니다"; + } + + /** + * Example 도메인 관련 에러 메시지 + */ + public static final class Example { + private Example() {} + + public static final String EXAMPLE_NOT_FOUND = "예시를 찾을 수 없습니다."; + public static final String NAME_REQUIRED = "이름은 비어있을 수 없습니다."; + public static final String DESCRIPTION_REQUIRED = "설명은 비어있을 수 없습니다."; + } + + /** + * Auth 관련 에러 메시지 + */ + public static final class Auth { + private Auth() {} + + public static final String AUTH_HEADER_MISSING = "인증 헤더가 누락되었습니다."; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..389aa8577 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.brand; + +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import java.util.Optional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @InjectMocks + private BrandService brandService; + + @Mock + private BrandRepository brandRepository; + + @Test + @DisplayName("브랜드 정보를 수정할 때 해당 브랜드가 존재하지않으면 예외를 던진다") + void fail_modify_not_found() { + Long id = 10L; + String name = "나이키"; + String description = "나이키설명"; + + given(brandRepository.findById(id)).willReturn(Optional.empty()); + + Assertions.assertThatThrownBy(() -> brandService.update(id, name, description)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } + + @Test + @DisplayName("id로 브랜드를 조회할 때 브랜드가 존재하지않으면 예외를 던진다") + void fail_getById_not_found_brand() { + Long id = 10L; + + given(brandRepository.findById(id)).willReturn(Optional.empty()); + + Assertions.assertThatThrownBy(() -> brandService.getById(id)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..17d80b504 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * 브랜드 도메인 테스트 + */ +class BrandTest { + + @DisplayName("브랜드 도메인 생성 성공 테스트") + @Test + void success_create_brand() { + String name = "brand1"; + String description = "description1"; + + Brand brand = Brand.create(null, name, description); + + Assertions.assertThat(brand.getName()).isEqualTo(name); + Assertions.assertThat(brand.getDescription()).isEqualTo(description); + } + + @DisplayName("브랜드 이름이 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_create_brand_with_invalid_name(String name) { + String description = "description1"; + + Assertions.assertThatThrownBy(() -> Brand.create(null, name, description)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드 이름은 필수 입니다"); + } + + @DisplayName("브랜드 이름이 유효하지 않다면, 수정시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_update_brand_with_invalid_name(String name) { + Brand brand = Brand.create(null, "brand1", "description1"); + + Assertions.assertThatThrownBy(() -> brand.update(name, "description2")) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드 이름은 필수 입니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java index 44ca7576e..2a47cbf04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -26,7 +26,7 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { // assert assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getId()).isNull(), () -> assertThat(exampleModel.getName()).isEqualTo(name), () -> assertThat(exampleModel.getDescription()).isEqualTo(description) ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..7e7a282fe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,93 @@ +package com.loopers.domain.like; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import com.loopers.support.error.CoreException; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @Nested + @DisplayName("좋아요 등록") + class LikeTest { + + @Test + @DisplayName("좋아요를 등록한다") + void success_like() { + Long productId = 1L; + Long userId = 2L; + Like like = Like.create(1L, productId, userId); + + given(likeRepository.existByUniqueId(productId, userId)).willReturn(false); + given(likeRepository.save(any(Like.class))).willReturn(like); + + Like result = likeService.like(productId, userId); + + assertThat(result.getRefProductId()).isEqualTo(productId); + assertThat(result.getRefUserId()).isEqualTo(userId); + } + + @Test + @DisplayName("이미 좋아요를 누른 상품이면, 예외를 던진다") + void fail_when_already_liked() { + Long productId = 1L; + Long userId = 2L; + + given(likeRepository.existByUniqueId(productId, userId)).willReturn(true); + + assertThatThrownBy(() -> likeService.like(productId, userId)) + .isInstanceOf(CoreException.class) + .hasMessage("이미 좋아요를 누른 상품입니다"); + } + } + + @Nested + @DisplayName("좋아요 취소") + class UnlikeTest { + + @Test + @DisplayName("좋아요를 취소한다") + void success_unlike() { + Long productId = 1L; + Long userId = 2L; + Like like = Like.create(1L, productId, userId); + + given(likeRepository.findByUniqueId(productId, userId)).willReturn(Optional.of(like)); + + likeService.unlike(productId, userId); + + then(likeRepository).should().delete(like); + } + + @Test + @DisplayName("좋아요가 존재하지 않으면, 예외를 던진다") + void fail_when_not_liked() { + Long productId = 1L; + Long userId = 2L; + + given(likeRepository.findByUniqueId(productId, userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> likeService.unlike(productId, userId)) + .isInstanceOf(CoreException.class) + .hasMessage("좋아요 객체를 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..5949a2dfd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,38 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class LikeTest { + + @DisplayName("좋아요의 상품 정보가 유효하지 않으면, 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-100L}) + void fail_when_invalid_ref_product_id(Long refProductId) { + Long id = 100L; + Long refUserId = 100L; + + assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) + .isInstanceOf(CoreException.class) + .hasMessage("상품FK는 null이거나 음수가 될 수 없습니다"); + } + + @DisplayName("좋아요의 유저 정보가 유효하지 않으면, 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-100L}) + void fail_when_invalid_ref_user_id(Long refUserId) { + Long id = 100L; + Long refProductId = 100L; + + assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) + .isInstanceOf(CoreException.class) + .hasMessage("유저FK는 null이거나 음수가 될 수 없습니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java new file mode 100644 index 000000000..3dd364874 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java @@ -0,0 +1,78 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OrderItemServiceTest { + + @InjectMocks + private OrderItemService orderItemService; + + @Nested + @DisplayName("주문 아이템 생성") + class CreateOrderItems { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 목록이 null이거나 비어있으면, 예외를 던진다") + void fail_when_products_is_null_or_empty(List products) { + Long orderId = 1L; + Map productQuantities = Map.of(1L, 2); + + assertThatThrownBy(() -> orderItemService.createOrderItems(orderId, products, productQuantities)) + .isInstanceOf(CoreException.class) + .hasMessage("주문할 상품이 없습니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("주문 수량 정보가 null이거나 비어있으면, 예외를 던진다") + void fail_when_quantities_is_null_or_empty(Map productQuantities) { + Long orderId = 1L; + List products = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0) + ); + + assertThatThrownBy(() -> orderItemService.createOrderItems(orderId, products, productQuantities)) + .isInstanceOf(CoreException.class) + .hasMessage("주문 수량 정보가 없습니다"); + } + } + + @Nested + @DisplayName("총 가격 계산") + class CalculateTotalPrice { + + @Test + @DisplayName("상품 목록과 수량으로 총 가격을 계산한다") + void success_calculate_total_price() { + List products = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0), + Product.create(2L, "상품2", 1L, 500, 10, 0) + ); + Map productQuantities = Map.of( + 1L, 2, + 2L, 3 + ); + + Money totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); + + assertThat(totalPrice.value()).isEqualTo(3500); + } + } +} 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 new file mode 100644 index 000000000..9eed7a70c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,87 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderItemTest { + + private static final Long DEFAULT_ORDER_ID = 1L; + private static final Long DEFAULT_PRODUCT_ID = 1L; + private static final Integer DEFAULT_QUANTITY = 2; + private static final Integer DEFAULT_PRICE = 10000; + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 항목 생성") + class Create { + + @Test + @DisplayName("주문 항목 생성에 성공한다") + void success_create_order_item() { + OrderItem orderItem = OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, DEFAULT_PRICE); + + assertThat(orderItem.refOrderId()).isEqualTo(DEFAULT_ORDER_ID); + assertThat(orderItem.refProductId()).isEqualTo(DEFAULT_PRODUCT_ID); + assertThat(orderItem.quantity()).isEqualTo(DEFAULT_QUANTITY); + assertThat(orderItem.price()).isEqualTo(new Money(DEFAULT_PRICE)); + } + + @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_order_id(Long refOrderId) { + assertCoreException( + () -> OrderItem.create(null, refOrderId, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, DEFAULT_PRICE), + ErrorMessage.Order.ORDER_ID_INVALID + ); + } + + @DisplayName("상품FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_product_id(Long refProductId) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, refProductId, DEFAULT_QUANTITY, DEFAULT_PRICE), + ErrorMessage.Order.PRODUCT_ID_INVALID + ); + } + + @DisplayName("수량이 null이거나 양수가 아니면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, 0}) + void fail_when_invalid_quantity(Integer quantity) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, quantity, DEFAULT_PRICE), + ErrorMessage.Order.QUANTITY_MUST_BE_POSITIVE + ); + } + + @DisplayName("주문 금액이 null이거나 음수이면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, -1000}) + void fail_when_invalid_price(Integer price) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, price), + ErrorMessage.Money.AMOUNT_INVALID + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java new file mode 100644 index 000000000..faaa35544 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderStatusHistoryTest { + + private static final Long DEFAULT_ORDER_ID = 1L; + private static final ZonedDateTime DEFAULT_CHANGED_AT = ZonedDateTime.now(); + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 상태 이력 생성") + class Create { + + @Test + @DisplayName("주문 상태 이력 생성에 성공한다") + void success_create_order_status_history() { + OrderStatusHistory history = OrderStatusHistory.create( + null, DEFAULT_ORDER_ID, OrderStatus.ORDERED, DEFAULT_CHANGED_AT + ); + + assertThat(history.refOrderId()).isEqualTo(DEFAULT_ORDER_ID); + assertThat(history.status()).isEqualTo(OrderStatus.ORDERED); + assertThat(history.changedAt()).isEqualTo(DEFAULT_CHANGED_AT); + } + + @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_order_id(Long refOrderId) { + assertCoreException( + () -> OrderStatusHistory.create(null, refOrderId, OrderStatus.ORDERED, DEFAULT_CHANGED_AT), + "주문FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @Test + @DisplayName("주문 상태가 null이면, 생성시 예외를 던진다") + void fail_when_status_is_null() { + assertCoreException( + () -> OrderStatusHistory.create(null, DEFAULT_ORDER_ID, null, DEFAULT_CHANGED_AT), + "주문 상태는 필수입니다" + ); + } + + @Test + @DisplayName("주문 상태 변경 일시가 null이면, 생성시 예외를 던진다") + void fail_when_changed_at_is_null() { + assertCoreException( + () -> OrderStatusHistory.create(null, DEFAULT_ORDER_ID, OrderStatus.ORDERED, null), + "주문 상태 변경 일시는 필수입니다" + ); + } + } +} 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 new file mode 100644 index 000000000..f0ee04e3b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,103 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderTest { + + private static final Long DEFAULT_USER_ID = 1L; + private static final Integer DEFAULT_TOTAL_PRICE = 10000; + private static final ZonedDateTime DEFAULT_ORDER_DT = ZonedDateTime.now(); + + private static Order createOrder(Long refUserId, Integer totalPrice, ZonedDateTime orderDt) { + return Order.create(null, refUserId, OrderStatus.ORDERED, totalPrice, orderDt); + } + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 생성") + class Create { + + @Test + @DisplayName("주문 생성에 성공한다") + void success_create_order() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + + assertThat(order.getRefUserId()).isEqualTo(DEFAULT_USER_ID); + assertThat(order.getTotalPrice()).isEqualTo(new Money(DEFAULT_TOTAL_PRICE)); + assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED); + assertThat(order.getOrderDt()).isEqualTo(DEFAULT_ORDER_DT); + } + + @DisplayName("유저 정보가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_user_id(Long refUserId) { + assertCoreException( + () -> createOrder(refUserId, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT), + ErrorMessage.Order.USER_ID_INVALID + ); + } + + @DisplayName("총 주문 금액이 null이거나 음수이면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, -1000}) + void fail_when_invalid_total_price(Integer totalPrice) { + assertCoreException( + () -> createOrder(DEFAULT_USER_ID, totalPrice, DEFAULT_ORDER_DT), + ErrorMessage.Money.AMOUNT_INVALID + ); + } + + @Test + @DisplayName("주문 일시가 null이면, 생성시 예외를 던진다") + void fail_when_order_dt_is_null() { + assertCoreException( + () -> createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, null), + ErrorMessage.Order.ORDER_DT_REQUIRED + ); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @Test + @DisplayName("주문 완료 상태에서 취소하면, 상태가 취소로 변경된다") + void success_cancel_order() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("이미 취소된 주문을 취소하면, 예외를 던진다") + void fail_when_already_cancelled() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + order.cancel(); + + assertCoreException(() -> order.cancel(), ErrorMessage.Order.CANCEL_ONLY_WHEN_COMPLETED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..6259f6e0e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,97 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + private ProductService productService; + + @Mock + private ProductRepository productRepository; + + @Nested + @DisplayName("상품 생성") + class CreateProducts { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 생성 요청이 null이거나 비어있으면, 예외를 던진다") + void fail_when_command_is_null_or_empty(Map command) { + assertThatThrownBy(() -> productService.createProducts(command)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 생성 요청은 필수입니다"); + } + } + + @Nested + @DisplayName("재고 차감") + class DecreaseStock { + + @Test + @DisplayName("상품이 존재하지 않으면, 예외를 던진다") + void fail_when_product_not_found() { + Long productId = 10101L; + Integer decreaseStock = 100; + + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> productService.decreaseStock(productId, decreaseStock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("상품 조회") + class GetProducts { + + @Test + @DisplayName("검색 조건이 null이면, 예외를 던진다") + void fail_when_condition_is_null() { + assertThatThrownBy(() -> productService.findProducts(null)) + .isInstanceOf(CoreException.class) + .hasMessage("검색 조건은 필수입니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 ID 목록이 null이거나 비어있으면, 예외를 던진다") + void fail_when_ids_is_null_or_empty(List ids) { + assertThatThrownBy(() -> productService.getByIds(ids)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 ID 목록은 필수입니다"); + } + + @Test + @DisplayName("존재하지 않는 상품이 포함되어 있으면, 예외를 던진다") + void fail_when_some_products_not_found() { + List ids = List.of(1L, 2L, 3L); + List foundProducts = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0) + ); + + given(productRepository.findByIds(ids)).willReturn(foundProducts); + + assertThatThrownBy(() -> productService.getByIds(ids)) + .isInstanceOf(CoreException.class) + .hasMessage("존재하지 않는 상품이 포함되어 있습니다"); + } + } +} 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 new file mode 100644 index 000000000..79578d833 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,197 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class ProductTest { + + private static final String DEFAULT_NAME = "product1"; + private static final Long DEFAULT_REF_BRAND_ID = 105L; + private static final Integer DEFAULT_PRICE = 1000; + private static final Integer DEFAULT_STOCK = 100; + private static final Integer DEFAULT_LIKE_COUNT = 0; + + private static Product createProduct(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { + return Product.create(id, name, refBrandId, price, stock, likeCount); + } + + private static Product createProductWithStock(int stock) { + return createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, stock, DEFAULT_LIKE_COUNT); + } + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("상품 생성") + class Create { + + @DisplayName("상품 도메인 객체 생성 테스트") + @Test + void success_create_product() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT); + + assertThat(product).isNotNull(); + assertThat(product.getId()).isNull(); + assertThat(product.getName()).isEqualTo(DEFAULT_NAME); + assertThat(product.getRefBrandId()).isEqualTo(DEFAULT_REF_BRAND_ID); + assertThat(product.getPrice()).isEqualTo(new Money(DEFAULT_PRICE)); + assertThat(product.getStock()).isEqualTo(DEFAULT_STOCK); + assertThat(product.getLikeCount()).isEqualTo(DEFAULT_LIKE_COUNT); + } + + @DisplayName("상품 이름이 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_when_invalid_name(String name) { + assertCoreException( + () -> createProduct(null, name, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + "상품 이름은 필수 입니다" + ); + } + + @DisplayName("상품의 브랜드 정보가 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-10000L, -1L, 0L}) + void fail_when_invalid_brand_id(Long refBrandId) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, refBrandId, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + "브랜드FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @DisplayName("상품 가격이 null이거나 음수라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10000, -1}) + void fail_when_invalid_price(Integer price) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, price, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + ErrorMessage.Money.AMOUNT_INVALID + ); + } + + @DisplayName("상품 재고가 null이거나 0 이하라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_when_invalid_stock(Integer stock) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, stock, DEFAULT_LIKE_COUNT), + "상품 재고는 null이거나 음수가 될 수 없습니다" + ); + } + + @DisplayName("좋아요 수가 null이거나 0 미만이라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_when_invalid_like_count(Integer likeCount) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, likeCount), + "좋아요 수는 null이거나 음수가 될 수 없습니다" + ); + } + } + + @Nested + @DisplayName("재고 검증") + class StockValidation { + + @DisplayName("요청 수량보다 재고가 많으면 true, 재고가 적으면 false") + @ParameterizedTest(name = "재고={0}, 요청={1} → {2}") + @CsvSource({ + "1000, 1, true", + "1, 1000, false" + }) + void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolean expected) { + Product product = createProductWithStock(stock); + + assertThat(product.hasEnoughStock(requiredQuantity)).isEqualTo(expected); + } + } + + @Nested + @DisplayName("재고 차감") + class StockDecrease { + + @DisplayName("재고 차감에 성공하면, 재고가 요청 수량만큼 줄어든다") + @Test + void success_decrease_stock() { + Product product = createProductWithStock(100); + + product.decreaseStock(30); + + assertThat(product.getStock()).isEqualTo(70); + } + + @DisplayName("재고보다 많은 수량을 차감하면, 예외를 던진다") + @Test + void fail_when_not_enough() { + Product product = createProductWithStock(10); + + assertCoreException(() -> product.decreaseStock(100), "재고가 부족합니다"); + } + + @DisplayName("재고 차감 수량이 null이거나 양수가 아니면, 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10, -1, 0}) + void fail_when_quantity_invalid(Integer quantity) { + Product product = createProduct(1L, DEFAULT_NAME, 1L, 10000, 100, 0); + + assertCoreException(() -> product.decreaseStock(quantity), "수량은 양수여야 합니다"); + } + } + + @Nested + @DisplayName("좋아요 수 변경") + class LikeCount { + + @DisplayName("좋아요 수 증가 성공") + @Test + void success_increase() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 5); + + product.increaseLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(6); + } + + @DisplayName("좋아요 수 감소 성공") + @Test + void success_decrease() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 5); + + product.decreaseLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(4); + } + + @DisplayName("좋아요 수가 0일 때 감소하면, 예외를 던진다") + @Test + void fail_decrease_when_like_count_is_zero() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 0); + + assertCoreException(() -> product.decreaseLikeCount(), "좋아요 갯수는 음수가 될 수 없습니다"); + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 2476123fd..ebfcf0838 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.BDDMockito.given; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -45,7 +46,7 @@ void fail_when_password_contains_birthDate() { // act & assert assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } @Test @@ -61,7 +62,7 @@ void fail_when_email_already_exists() { assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("이미 가입된 이메일입니다"); + .hasMessageContaining(ErrorMessage.User.EMAIL_ALREADY_EXISTS); } @Test @@ -77,7 +78,7 @@ void fail_when_loginId_already_exists() { assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("이미 사용 중인 아이디입니다"); + .hasMessageContaining(ErrorMessage.User.LOGIN_ID_ALREADY_EXISTS); } } @@ -121,7 +122,7 @@ void fail_when_currentPassword_not_match() { assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("기존 비밀번호가 일치하지 않습니다"); + .hasMessageContaining(ErrorMessage.User.CURRENT_PASSWORD_MISMATCH); } @Test @@ -140,7 +141,7 @@ void fail_when_newPassword_same_as_current() { assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("새 비밀번호는 기존 비밀번호와 달라야 합니다"); + .hasMessageContaining(ErrorMessage.User.NEW_PASSWORD_SAME_AS_CURRENT); } @Test @@ -161,7 +162,7 @@ void fail_when_newPassword_contains_birthDate() { // act & assert assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } @Test @@ -177,7 +178,7 @@ void fail_when_user_not_found() { // act & assert assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.USER_NOT_FOUND); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..fe50f15c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,207 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.user.UserModel; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.time.LocalDate; +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.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.security.crypto.password.PasswordEncoder; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + private UserModel savedUser; + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Password1!"; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + savedUser = userJpaRepository.save( + UserModel.create(LOGIN_ID, passwordEncoder.encode(LOGIN_PW), LocalDate.of(1990, 1, 1), "테스트유저", "test@test.com") + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/products/{productId}/like") + class ToggleLike { + + @Test + @DisplayName("좋아요를 누르면, 좋아요가 추가된다") + void success_add_like() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 0) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isTrue() + ); + } + + @Test + @DisplayName("이미 좋아요한 상품에 다시 좋아요를 누르면, 좋아요가 취소된다") + void success_remove_like() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 1) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + // 첫 번째 좋아요 + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isTrue(); + + // 두 번째 좋아요 (취소) + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isFalse() + ); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요를 누르면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/products/99999/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다") + void fail_when_unauthorized() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 0) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_ID, LOGIN_ID); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_PW, LOGIN_PW); + headers.set("Content-Type", "application/json"); + return headers; + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..17fdbb666 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,259 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.user.UserModel; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.time.LocalDate; +import java.util.List; +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.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.security.crypto.password.PasswordEncoder; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + private UserModel savedUser; + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Password1!"; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + savedUser = userJpaRepository.save( + UserModel.create(LOGIN_ID, passwordEncoder.encode(LOGIN_PW), LocalDate.of(1990, 1, 1), "테스트유저", "test@test.com") + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/orders") + class CreateOrder { + + @Test + @DisplayName("주문을 생성하면, 주문 정보를 반환한다") + void success_create_order() { + ProductEntity product1 = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + ProductEntity product2 = productJpaRepository.save( + createProductEntity("상품2", savedBrand.getId(), 20000, 50, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(product1.getId(), 2), + new OrderV1Dto.OrderItemRequest(product2.getId(), 1) + ) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(40000) // 10000*2 + 20000*1 + ); + } + + @Test + @DisplayName("주문 후 재고가 차감된다") + void success_decrease_stock_after_order() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 10)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + ProductEntity updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock()).isEqualTo(90); + } + + @Test + @DisplayName("재고가 부족하면, 400 응답을 반환한다") + void fail_when_stock_not_enough() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 5, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 10)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 상품을 주문하면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(99999L, 1)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("주문 상품 목록이 비어있으면, 400 응답을 반환한다") + void fail_when_order_items_empty() { + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest(List.of()); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다") + void fail_when_unauthorized() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 1)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_ID, LOGIN_ID); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_PW, LOGIN_PW); + headers.set("Content-Type", "application/json"); + return headers; + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..c029322dd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,201 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/products/{productId}") + class GetProduct { + + @Test + @DisplayName("존재하는 상품 ID로 조회하면, 상품 상세 정보를 반환한다") + void success_get_product() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 10) + ); + + String url = "/api/v1/products/" + product.getId(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키") + ); + } + + @Test + @DisplayName("존재하지 않는 상품 ID로 조회하면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/products/99999"; + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("GET /api/v1/products") + class GetProducts { + + @Test + @DisplayName("상품 목록을 조회한다") + void success_get_products() { + productJpaRepository.save(createProductEntity("상품1", savedBrand.getId(), 10000, 50, 5)); + productJpaRepository.save(createProductEntity("상품2", savedBrand.getId(), 20000, 30, 10)); + + String url = "/api/v1/products"; + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2) + ); + } + + @Test + @DisplayName("브랜드 ID로 필터링하여 조회한다") + void success_get_products_by_brand() { + BrandEntity anotherBrand = brandJpaRepository.save(createBrandEntity("아디다스", "독일 브랜드")); + productJpaRepository.save(createProductEntity("나이키상품", savedBrand.getId(), 10000, 50, 5)); + productJpaRepository.save(createProductEntity("아디다스상품", anotherBrand.getId(), 20000, 30, 10)); + + String url = "/api/v1/products?brandId=" + savedBrand.getId(); + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).name()).isEqualTo("나이키상품") + ); + } + + @Test + @DisplayName("가격 오름차순으로 정렬하여 조회한다") + void success_get_products_sorted_by_price() { + productJpaRepository.save(createProductEntity("비싼상품", savedBrand.getId(), 50000, 50, 5)); + productJpaRepository.save(createProductEntity("싼상품", savedBrand.getId(), 10000, 30, 10)); + + String url = "/api/v1/products?sortType=PRICE_ASC"; + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get(0).name()).isEqualTo("싼상품"), + () -> assertThat(response.getBody().data().get(1).name()).isEqualTo("비싼상품") + ); + } + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java index 1cfd7ef64..ef8f71ec2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api; import com.loopers.domain.user.UserModel; -import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.interfaces.user.ChangePasswordRequest; import com.loopers.interfaces.user.UserDto; import com.loopers.interfaces.user.UsersSignUpRequestDto; 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..72fdc2ece 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -20,7 +20,7 @@ public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private final Long id = 0L; + private Long id; @Column(name = "created_at", nullable = false, updatable = false) private ZonedDateTime createdAt;