diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..daad56c7c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +AI 어시스턴트가 본 프로젝트의 코딩 규칙, 아키텍처, 도메인 설계 전략을 준수하도록 안내하는 문서입니다. + +--- + +## 객체지향 & 도메인 모델링 규칙 + +### 핵심 원칙 + +- **도메인 객체는 비즈니스 규칙을 캡슐화**한다. 데이터 저장소가 아닌, 규칙과 행위를 가진 객체로 설계한다. +- **애플리케이션 서비스**는 서로 다른 도메인을 조립하고, 도메인 로직을 조정하여 기능을 제공한다. 비즈니스 규칙을 직접 구현하지 않는다. +- **규칙이 여러 서비스에 반복되면** 도메인 객체(Entity, VO, Domain Service)로 옮길 가능성이 높다. +- 각 기능의 **책임과 결합도**를 명확히 하고, 개발 의도에 맞게 설계한다. + +### Entity 설계 + +- **고유 식별자(ID)**를 가지며, 자신의 상태를 변경하는 **행위 메서드**를 제공한다. +- `changePassword()`, `cancelOrder()`, `decreaseStock()`처럼 **의도가 드러나는 메서드명**을 사용한다. +- **무분별한 Setter 사용을 금지**한다. 상태 변경은 반드시 도메인 메서드를 통해 이루어진다. +- 생성자와 비즈니스 메서드 내부에서 **필수 검증**을 수행한다. + +### Value Object (VO) 설계 + +- **식별자가 없는** 값 객체이다. +- **불변(Immutable)**으로 설계하고, 생성 시점에 **자체 검증 로직**을 포함한다. +- `Money`, `Quantity`, `LoginId`, `Email`처럼 의미 있는 단위로 분리한다. +- 동일성은 **값의 동등성**으로 판단한다. + +### Domain Service 설계 + +- **특정 엔티티에 두기 어려운** 여러 엔티티 간 조율이나 복잡한 도메인 정책을 처리한다. +- **무상태(Stateless)**로 설계한다. +- **동일한 도메인 경계 내**의 도메인 객체 협력에 집중한다. +- 도메인 내부 규칙은 Domain Service에 두고, Application Layer는 조합만 담당한다. + +### 빈약한 도메인 모델 지양 + +- 비즈니스 로직을 Application Service에 두지 말고, **Entity와 VO 내부에 응집**시킨다. +- Getter/Setter만 있는 데이터 클래스는 지양하고, **행위가 드러나는 메서드**를 우선한다. + +--- + +## 아키텍처 전략 & 패키지 구성 + +### 레이어드 아키텍처 + DIP + +- 본 프로젝트는 **레이어드 아키텍처**를 따르며, **DIP(의존성 역전 원칙)**를 준수한다. +- 의존성 방향: **Infrastructure → Domain ← Application** +- **Domain Layer**는 외부 기술(JPA, Spring 등)에 의존하지 않는다. + +### 계층 구조 + +``` +Application ──→ Domain ←── Infrastructure + │ │ │ + │ │ └── Repository 구현체, JPA Entity, Mapper + │ └── Entity, VO, Domain Service, Repository Interface + └── Service, Facade (도메인 조합, 흐름 제어) +``` + +- **Interfaces (Presentation)**: HTTP 요청/응답, Request/Response DTO, 입력 검증. 비즈니스 로직 없음. +- **Application**: 트랜잭션 관리, 도메인 조합, 흐름 제어. 로직은 도메인에 위임. +- **Domain**: 순수 도메인 객체, Repository Interface. 외부 의존성 0%. +- **Infrastructure**: Repository 구현체, JPA Entity, DB/Redis 등 기술 구현. + +### DTO 분리 + +- **API Request/Response DTO**와 **Application Layer DTO**는 분리해 작성한다. +- API DTO는 `interfaces.api.*`에, Application DTO는 `application.*`에 위치한다. + +### 패키지 구성 + +4개 레이어 패키지를 두고, 하위에 **도메인별**로 패키징한다. + +``` +/interfaces/api/{domain} # Presentation - API Controller, API DTO +/application/{domain} # Application - Service, Facade, Application DTO +/domain/{domain} # Domain - Entity, VO, Domain Service, Repository Interface +/infrastructure/{domain} # Infrastructure - Repository 구현체, JPA Entity, Mapper +``` + +**예시** + +``` +com.loopers +├── interfaces +│ └── api +│ ├── product +│ ├── order +│ └── like +├── application +│ ├── product +│ │ ├── ProductFacade +│ │ └── ProductInfo +│ └── order +│ └── OrderService +├── domain +│ ├── product +│ │ ├── Product +│ │ ├── Brand +│ │ ├── Stock +│ │ └── ProductRepository +│ ├── like +│ │ ├── Like +│ │ └── LikeRepository +│ └── order +│ ├── Order +│ ├── OrderLine +│ └── OrderRepository +└── infrastructure + ├── product + │ ├── ProductRepositoryImpl + │ └── ProductJpaRepository + └── order + └── OrderRepositoryImpl +``` + +### Service vs Facade + +- **Service**: 트랜잭션 관리, **상태 변경**이 있는 복잡한 비즈니스 흐름. 도메인 객체에 위임. +- **Facade**: **상태 변경 없이** 여러 도메인의 데이터를 조회·조합(Aggregation)하여 반환. + +--- + +## 도메인 설계 가이드 (Product, Brand, Like, Order) + +### Product / Brand + +- 상품 정보는 **브랜드 정보**, **좋아요 수**를 포함한다. +- 상품 정렬 조건(`latest`, `price_asc`, `likes_desc`)은 **조회 시점**에 적용한다. +- 상품은 **재고(Stock)**를 가지며, 주문 시 **도메인 레벨**에서 차감한다. +- **재고 음수 방지**는 Entity 또는 Domain Service에서 처리한다. + +### Like + +- 좋아요는 **유저와 상품 간 관계**로 별도 도메인으로 분리한다. +- 상품의 좋아요 수는 **조회 시점에 집계**하여 상품 상세/목록에 함께 제공한다. +- 상품이 좋아요 수를 **직접 관리하지 않는다**. Like 도메인이 집계를 담당한다. + +### Order + +- 주문은 **여러 상품**을 포함하며, 각 상품의 **수량**을 명시한다. +- 주문 시 **상품 재고 차감**을 수행한다. 재고 부족 시 예외 처리한다. +- Order, Product, Stock 간 협력은 **Domain Service**에서 조율한다. + +--- + +## Feature Suggestions (설계 의사결정 가이드) + +### Q1. 상품이 좋아요 수를 직접 관리해야 할까? + +**아니오.** 좋아요 수는 Like 도메인에서 집계한다. Product는 `likeCount`를 필드로 가지지 않고, 조회 시점에 Application Layer 또는 Facade에서 Product + Like를 조합해 제공한다. 이렇게 하면 좋아요 등록/취소 시 Product를 수정할 필요가 없고, Like 도메인이 단일 책임을 가진다. + +### Q2. 상품 상세에서 브랜드를 함께 제공하려면 누가 조합해야 할까? + +**Application Layer (ProductFacade)**가 조합한다. `ProductFacade.getProductDetail(productId)`에서 Product, Brand, Like를 조회해 하나의 DTO로 조합한다. Domain Layer는 각자 자신의 책임만 수행하고, 조합은 Application의 역할이다. + +### Q3. VO를 도입한 이유는 무엇이며, 어느 시점에서 유리하게 작용했는가? + +- **검증 로직 응집**: `Money`, `Quantity`처럼 생성 시점에 유효성 검증을 캡슐화한다. +- **불변성 보장**: 값이 변경되지 않아 부작용을 줄인다. +- **의미 표현**: `Price price`가 `long price`보다 의도를 잘 드러낸다. +- **재사용**: 여러 Entity에서 동일한 VO를 사용해 일관된 규칙을 적용한다. + +### Q4. Order, Product, User 중 누가 어떤 책임을 갖는 것이 자연스러웠나? + +- **Order**: 주문 생성, 주문 라인 관리, 주문 상태 변경. "주문한다"는 Order의 책임. +- **Product**: 상품 정보, 재고 차감(`decreaseStock()`). "재고를 줄인다"는 Product(또는 Stock)의 책임. +- **User/Member**: 회원 정보, 인증. 주문 시에는 식별자만 참조한다. +- **Domain Service**: Order와 Product 간 재고 차감·검증 등 **여러 엔티티 협력**은 Domain Service가 조율한다. + +### Q5. Repository Interface를 Domain Layer에 두는 이유는? + +**DIP 적용**을 위해서다. Application/Domain은 "저장·조회" 인터페이스만 알고, 실제 구현(JDBC, JPA 등)은 Infrastructure에 둔다. Domain이 Infrastructure에 의존하지 않도록 인터페이스를 Domain에 두고, 구현체가 이를 따른다. + +### Q6. 처음엔 도메인에 두려 했지만, 결국 Application Layer로 옮긴 이유는? + +- **트랜잭션 경계**: `@Transactional`은 Application Layer에서 관리하는 것이 자연스럽다. +- **여러 도메인 조합**: Product + Brand + Like 조합은 단일 도메인 책임을 넘어서므로 Application(Facade)에 둔다. +- **외부 의존성**: Domain은 Spring, JPA 등에 의존하지 않아야 하므로, 트랜잭션 어노테이션을 쓰는 클래스는 Application에 둔다. + +### Q7. 테스트 가능한 구조를 만들기 위해 가장 먼저 고려한 건 무엇이었나? + +- **Repository Interface 분리**: Domain에 인터페이스를 두고, 단위 테스트에서는 **Fake/Stub 구현체**를 주입한다. +- **도메인 로직 순수성**: Entity, VO, Domain Service가 외부 의존 없이 동작하도록 설계해, **단위 테스트만으로** 비즈니스 규칙을 검증한다. +- **의존성 주입**: Service가 Repository 인터페이스에 의존하도록 해, 테스트 시 Mock/Fake로 대체 가능하게 한다. diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..cb54a44be 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/docs/README.md b/apps/commerce-api/docs/README.md new file mode 100644 index 000000000..04099d0bd --- /dev/null +++ b/apps/commerce-api/docs/README.md @@ -0,0 +1,28 @@ +# Commerce API 문서 + +Claude Code / AI 어시스턴트 작업 시 참고할 도메인별 문서입니다. + +## 문서 위치 + +각 구현체(도메인, Application Layer) 폴더에 README.md가 있습니다. + +| 영역 | 경로 | +|------|------| +| Product/Brand 도메인 | `src/main/java/com/loopers/domain/product/README.md` | +| Like 도메인 | `src/main/java/com/loopers/domain/like/README.md` | +| Order 도메인 | `src/main/java/com/loopers/domain/order/README.md` | +| Product Application | `src/main/java/com/loopers/application/product/README.md` | +| Like Application | `src/main/java/com/loopers/application/like/README.md` | +| Order Application | `src/main/java/com/loopers/application/order/README.md` | + +## Cursor Rules + +`.cursor/rules/`에 도메인별 규칙이 등록되어 있습니다. 해당 경로의 파일을 편집할 때 자동으로 적용됩니다. + +- `domain-product.mdc` — Product, Brand 관련 +- `domain-like.mdc` — Like 관련 +- `domain-order.mdc` — Order 관련 + +## 전체 아키텍처 + +프로젝트 루트의 `CLAUDE.md`를 참고하세요. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..4bb889d4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,33 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void like(Long memberId, Long productId) { + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + return; + } + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] 상품을 찾을 수 없습니다.")); + likeRepository.save(new Like(memberId, productId)); + } + + @Transactional + public void unlike(Long memberId, Long productId) { + likeRepository.deleteByMemberIdAndProductId(memberId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/README.md b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md new file mode 100644 index 000000000..e500a78d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md @@ -0,0 +1,29 @@ +# Like Application Layer + +> Claude Code 작업 시 이 영역의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **LikeService**: 좋아요 등록/취소. 트랜잭션 관리, 도메인에 위임. + +## 설계 규칙 + +1. **Service = 트랜잭션 + 흐름 제어** + 비즈니스 로직은 Like, Product 도메인에 위임. + +2. **멱등성** + `like()` — 이미 존재하면 무시. `unlike()` — 없어도 예외 없음. + +3. **Product 존재 검증** + 좋아요 등록 전 `productRepository.findById()`로 상품 존재 확인. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| LikeService | like(), unlike() | + +## 참조 + +- [domain/like README](../../domain/like/README.md) — Like 도메인 규칙 +- [CLAUDE.md](/CLAUDE.md) — 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..df7e4327d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,31 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderDomainService.OrderLineRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderDomainService orderDomainService; + + @Transactional + public OrderResult placeOrder(Long memberId, List items) { + Order order = orderDomainService.placeOrder(memberId, items); + List orderLines = order.getOrderLines().stream() + .map(ol -> new OrderLineInfo(ol.getProductId(), ol.getQuantity(), ol.getUnitPrice())) + .collect(Collectors.toList()); + return new OrderResult(order.getId(), order.getStatus(), order.getTotalAmount(), orderLines); + } + + public record OrderResult(Long orderId, String status, long totalAmount, List orderLines) {} + + public record OrderLineInfo(Long productId, int quantity, long unitPrice) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/README.md b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md new file mode 100644 index 000000000..8149cb557 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md @@ -0,0 +1,28 @@ +# Order Application Layer + +> Claude Code 작업 시 이 영역의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **OrderService**: 주문 생성. 트랜잭션 관리, OrderDomainService에 위임. + +## 설계 규칙 + +1. **Service = 트랜잭션 + 위임** + `placeOrder()` → `orderDomainService.placeOrder()` 호출. + 도메인 로직은 OrderDomainService, Product, Order에 위임. + +2. **OrderResult 변환** + Order 엔티티 → OrderResult (orderId, status, totalAmount, orderLines) → API DTO. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| OrderService | placeOrder() | +| OrderResult, OrderLineInfo | Application DTO | + +## 참조 + +- [domain/order README](../../domain/order/README.md) — Order 도메인 규칙 +- [CLAUDE.md](/CLAUDE.md) — 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..b43abe96f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +public record ProductDetailInfo( + Long id, + String name, + Long price, + int stockQuantity, + BrandInfo brand, + long likeCount +) { + public record BrandInfo(Long id, String name) {} + + public static ProductDetailInfo of(Product product, Brand brand, long likeCount) { + BrandInfo brandInfo = brand != null ? new BrandInfo(brand.getId(), brand.getName()) : null; + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getPrice(), + product.getStockQuantity(), + brandInfo, + likeCount + ); + } +} 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..771d85bd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + public ProductDetailInfo getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] 상품을 찾을 수 없습니다.")); + Brand brand = product.getBrandId() != null + ? brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")) + : null; + long likeCount = likeRepository.countByProductId(productId); + return ProductDetailInfo.of(product, brand, likeCount); + } + + public List getProductList(SortCondition sort) { + List products = productRepository.findAll(sort); + if (products.isEmpty()) { + return List.of(); + } + + List brandIds = products.stream() + .map(Product::getBrandId) + .filter(id -> id != null) + .distinct() + .toList(); + Map brandMap = brandIds.stream() + .flatMap(id -> brandRepository.findById(id).stream()) + .collect(Collectors.toMap(Brand::getId, b -> b)); + + List productIds = products.stream().map(Product::getId).toList(); + Map likeCountMap = likeRepository.countByProductIds(productIds); + + return products.stream() + .map(p -> ProductListInfo.of(p, brandMap, likeCountMap)) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java new file mode 100644 index 000000000..0bc57d49a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +import java.util.Map; + +public record ProductListInfo( + Long id, + String name, + Long price, + String brandName, + long likeCount +) { + public static ProductListInfo of(Product product, Brand brand, long likeCount) { + String brandName = brand != null ? brand.getName() : ""; + return new ProductListInfo( + product.getId(), + product.getName(), + product.getPrice(), + brandName, + likeCount + ); + } + + public static ProductListInfo of(Product product, Map brandMap, Map likeCountMap) { + Brand brand = product.getBrandId() != null ? brandMap.get(product.getBrandId()) : null; + long likeCount = likeCountMap.getOrDefault(product.getId(), 0L); + return of(product, brand, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/README.md b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md new file mode 100644 index 000000000..32709364b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md @@ -0,0 +1,35 @@ +# Product Application Layer + +> Claude Code 작업 시 이 영역의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **ProductFacade**: 상태 변경 없이 Product + Brand + Like를 조회·조합하여 반환. +- **ProductDetailInfo, ProductListInfo**: Application DTO (API DTO와 분리). + +## 설계 규칙 + +1. **Facade = 조합 전용** + 비즈니스 로직 없음. Domain Repository에서 조회 후 DTO로 변환. + +2. **상품 상세 조합** + `getProductDetail(productId)` → Product + Brand + likeCount 조합. + +3. **상품 목록 조합** + `getProductList(sort)` → ProductRepository.findAll(sort) + Brand 맵 + Like 집계 맵 → ProductListInfo 리스트. + +4. **정렬** + `latest`, `price_asc`, `likes_desc` — ProductRepository에 위임. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| ProductFacade | getProductDetail, getProductList | +| ProductDetailInfo | 상세 조회용 DTO | +| ProductListInfo | 목록 조회용 DTO | + +## 참조 + +- [domain/product README](../../domain/product/README.md) — Product 도메인 규칙 +- [CLAUDE.md](/CLAUDE.md) — 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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..e62fd686b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,47 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "product_like", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "product_id"}) +}) +public class Like extends BaseEntity { + + private Long memberId; + private Long productId; + + protected Like() {} + + public Like(Long memberId, Long productId) { + validateMemberId(memberId); + validateProductId(productId); + this.memberId = memberId; + this.productId = productId; + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + } + + private void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + } + + public Long getMemberId() { + return memberId; + } + + public Long getProductId() { + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..6d4cacfd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; + +public interface LikeRepository { + + Like save(Like like); + + void deleteByMemberIdAndProductId(Long memberId, Long productId); + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md new file mode 100644 index 000000000..9ddd1844d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md @@ -0,0 +1,35 @@ +# Like 도메인 + +> Claude Code 작업 시 이 도메인의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **Like**: 회원(Member)과 상품(Product) 간의 좋아요 관계. +- 별도 도메인으로 분리하여 Product가 좋아요 수를 직접 관리하지 않음. + +## 설계 규칙 + +1. **(member_id, product_id) UNIQUE** + 한 회원이 한 상품에 한 번만 좋아요 가능. + +2. **좋아요 수 집계** + `LikeRepository.countByProductId()`, `countByProductIds()` — 조회 시점에 집계. + +3. **Product와 분리** + Product 엔티티에 likeCount 필드 없음. Application Layer에서 조합. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| Like | 좋아요 엔티티 (memberId, productId) | +| LikeRepository | 저장, 삭제, 존재 여부, 집계 | + +## API 흐름 + +- **등록**: `LikeService.like()` → 중복 시 멱등, Product 존재 검증 후 저장 +- **취소**: `LikeService.unlike()` → `deleteByMemberIdAndProductId()` + +## 참조 + +- [CLAUDE.md](/CLAUDE.md) — 프로젝트 루트의 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..7e6c17b88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,81 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + private String loginId; + private String encryptedPassword; + private String name; + private String birthDate; + private String email; + + protected Member() {} + + public Member(String loginId, String encryptedPassword, String name, + String birthDate, String email) { + validateLoginId(loginId); + validatePassword(encryptedPassword); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.encryptedPassword = encryptedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 필수입니다"); + } + } + + private void validatePassword(String password) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다"); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다"); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다"); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다"); + } + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..f06cecd27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 000000000..cf76b7e5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member signUp(String loginId, String rawPassword, String name, String birthDate, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 사용 중인 로그인ID입니다."); + } + + String encryptedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member(loginId, encryptedPassword, name, birthDate, email); + return memberRepository.save(member); + } +} 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..fd7f2607d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,118 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + private static final String STATUS_ORDERED = "ORDERED"; + + private Long memberId; + private String status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderLines = new ArrayList<>(); + + protected Order() {} + + private Order(Long memberId, List orderLines) { + validateMemberId(memberId); + validateOrderLines(orderLines); + this.memberId = memberId; + this.status = STATUS_ORDERED; + for (OrderLine line : orderLines) { + this.orderLines.add(new OrderLineEntity(this, line.productId(), line.quantity(), line.unitPrice())); + } + } + + public static Order create(Long memberId, List orderLines) { + return new Order(memberId, orderLines); + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + } + + private void validateOrderLines(List orderLines) { + if (orderLines == null || orderLines.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있을 수 없습니다."); + } + } + + public Long getMemberId() { + return memberId; + } + + public String getStatus() { + return status; + } + + public List getOrderLines() { + return Collections.unmodifiableList(orderLines); + } + + public long getTotalAmount() { + return orderLines.stream() + .mapToLong(OrderLineEntity::getTotalPrice) + .sum(); + } + + @jakarta.persistence.Entity + @jakarta.persistence.Table(name = "order_line") + public static class OrderLineEntity { + @jakarta.persistence.Id + @jakarta.persistence.GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY) + private Long id; + + @jakarta.persistence.ManyToOne(fetch = jakarta.persistence.FetchType.LAZY) + @jakarta.persistence.JoinColumn(name = "order_id", nullable = false) + private Order order; + + @jakarta.persistence.Column(name = "product_id", nullable = false) + private Long productId; + + @jakarta.persistence.Column(nullable = false) + private int quantity; + + @jakarta.persistence.Column(name = "unit_price", nullable = false) + private long unitPrice; + + protected OrderLineEntity() {} + + OrderLineEntity(Order order, Long productId, int quantity, long unitPrice) { + this.order = order; + this.productId = productId; + this.quantity = quantity; + this.unitPrice = unitPrice; + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } + + public Long getProductId() { + return productId; + } + + public int getQuantity() { + return quantity; + } + + public long getUnitPrice() { + return unitPrice; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java new file mode 100644 index 000000000..47aa788db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderDomainService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + public Order placeOrder(Long memberId, List items) { + List orderLines = new ArrayList<>(); + for (OrderLineRequest item : items) { + Product product = productRepository.findById(item.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + item.productId() + "] 상품을 찾을 수 없습니다.")); + product.decreaseStock(item.quantity()); + orderLines.add(new OrderLine(item.productId(), item.quantity(), product.getPrice())); + } + Order order = Order.create(memberId, orderLines); + return orderRepository.save(order); + } + + public record OrderLineRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java new file mode 100644 index 000000000..622b7464e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java @@ -0,0 +1,23 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderLine(Long productId, int quantity, long unitPrice) { + + public OrderLine { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + if (unitPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "단가는 0 이상이어야 합니다."); + } + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } +} 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..cfd052c8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md new file mode 100644 index 000000000..04037e6b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md @@ -0,0 +1,42 @@ +# Order 도메인 + +> Claude Code 작업 시 이 도메인의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **Order**: 주문 엔티티. 여러 OrderLine 포함. +- **OrderLine**: 주문 항목 VO (productId, quantity, unitPrice 스냅샷). +- **OrderDomainService**: Order와 Product 간 재고 차감·주문 생성 조율. + +## 설계 규칙 + +1. **주문 시 재고 차감** + `OrderDomainService.placeOrder()`에서 각 Product에 `decreaseStock()` 호출. + 재고 부족 시 예외 → Order 미생성 (트랜잭션 롤백). + +2. **가격 스냅샷** + OrderLine에 주문 시점 `unitPrice` 저장. 이후 Product 가격 변경과 무관. + +3. **도메인 서비스** + Order, Product 간 협력은 `OrderDomainService`에서 처리. + Application Layer(OrderService)는 트랜잭션만 관리. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| Order | 주문 엔티티, `Order.create()` 정적 팩토리 | +| OrderLine | 주문 항목 VO (불변) | +| OrderDomainService | placeOrder — 재고 차감 + Order 생성 | +| OrderRepository | 주문 저장/조회 인터페이스 | + +## 주문 흐름 + +1. Product 조회 및 재고 검증 +2. `product.decreaseStock(quantity)` — 재고 부족 시 예외 +3. `Order.create(memberId, orderLines)` — Order + OrderLine 생성 +4. `orderRepository.save(order)` + +## 참조 + +- [CLAUDE.md](/CLAUDE.md) — 프로젝트 루트의 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java new file mode 100644 index 000000000..687015ea8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + private String name; + + protected Brand() {} + + public Brand(String name) { + validateName(name); + this.name = name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수입니다."); + } + } + + public String getName() { + return name; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java new file mode 100644 index 000000000..3491f67d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); +} 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..a0c55b125 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,81 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class Product extends BaseEntity { + + private Long brandId; + private String name; + private Long price; + private int stockQuantity; + + protected Product() {} + + public Product(Long brandId, String name, Long price, int stockQuantity) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + + this.brandId = brandId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수입니다."); + } + } + + private void validatePrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private void validateStockQuantity(int stockQuantity) { + if (stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (stockQuantity < quantity) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "재고가 부족합니다."); + } + this.stockQuantity -= quantity; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public int getStockQuantity() { + return stockQuantity; + } +} 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..2350033de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,13 @@ +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 findAll(SortCondition sort); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md new file mode 100644 index 000000000..c6a0f561a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md @@ -0,0 +1,37 @@ +# Product / Brand 도메인 + +> Claude Code 작업 시 이 도메인의 설계 의도와 규칙을 참고하세요. + +## 책임 + +- **Product**: 상품 정보, 재고 관리. `decreaseStock()`으로 주문 시 재고 차감. +- **Brand**: 브랜드 정보. Product가 `brandId`로 참조. + +## 설계 규칙 + +1. **Product는 likeCount를 직접 가지지 않음** + 좋아요 수는 Like 도메인에서 집계하며, 조회 시점에 Application Layer에서 조합. + +2. **재고 차감은 도메인 레벨에서 처리** + `Product.decreaseStock(quantity)` 내부에서 음수 방지. + 재고 부족 시 `CoreException(ErrorType.INSUFFICIENT_STOCK)` 발생. + +3. **행위 메서드 사용** + Setter 금지. `decreaseStock()` 등 의도가 드러나는 메서드 사용. + +4. **정렬 조건 (SortCondition)** + `latest`, `price_asc`, `likes_desc` — Infrastructure에서 구현. + +## 주요 클래스 + +| 클래스 | 역할 | +|--------|------| +| Product | 상품 엔티티, `decreaseStock()` | +| Brand | 브랜드 엔티티 | +| ProductRepository | 상품 저장/조회 인터페이스 | +| BrandRepository | 브랜드 저장/조회 인터페이스 | +| SortCondition | 정렬 조건 enum | + +## 참조 + +- [CLAUDE.md](/CLAUDE.md) — 프로젝트 루트의 전체 아키텍처 규칙 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java new file mode 100644 index 000000000..4d71bddb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum SortCondition { + latest, + price_asc, + likes_desc +} 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..530964b3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + @Modifying + @Query("DELETE FROM Like l WHERE l.memberId = :memberId AND l.productId = :productId") + void deleteByMemberIdAndProductId(@Param("memberId") Long memberId, @Param("productId") Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdsGroupByProductId(@Param("productIds") List productIds); +} 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..64bdd7d19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + likeJpaRepository.deleteByMemberIdAndProductId(memberId, productId); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + List results = likeJpaRepository.countByProductIdsGroupByProductId(productIds); + Map map = results.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + for (Long productId : productIds) { + map.putIfAbsent(productId, 0L); + } + return map; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..beebc4eca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..6116d454e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } +} 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..f2ee62050 --- /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 com.loopers.domain.order.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..309265e06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,25 @@ +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; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java new file mode 100644 index 000000000..d532f2d35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.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/product/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java new file mode 100644 index 000000000..ff9fedafc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } +} 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..4a831751a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + @Query("SELECT p FROM Product p LEFT JOIN com.loopers.domain.like.Like l ON l.productId = p.id " + + "WHERE p.deletedAt IS NULL GROUP BY p ORDER BY COUNT(l) DESC") + List findAllOrderByLikesDesc(); +} 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..f2c3a8e96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAll(SortCondition sort) { + return switch (sort) { + case latest -> productJpaRepository.findAll(Sort.by(Sort.Direction.DESC, "createdAt")); + case price_asc -> productJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "price")); + case likes_desc -> productJpaRepository.findAllOrderByLikesDesc(); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..b689957af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@Tag(name = "Like V1 API", description = "좋아요 API") +@RequestMapping("/api/v1/likes") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request); + + @Operation(summary = "좋아요 취소", description = "상품 좋아요를 취소합니다.") + ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ); +} 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..2bc2fbcad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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/likes") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeService likeService; + + @PostMapping + @Override + public ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request) { + likeService.like(request.memberId(), request.productId()); + return ApiResponse.success(); + } + + @DeleteMapping + @Override + public ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ) { + likeService.unlike(memberId, productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..0b7432bd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.like; + +import jakarta.validation.constraints.NotNull; + +public class LikeV1Dto { + + public record LikeRequest( + @NotNull(message = "회원 ID는 필수입니다") + Long memberId, + + @NotNull(message = "상품 ID는 필수입니다") + Long productId + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..c93074ef0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Member V1 API", description = "회원 API") +@RequestMapping("/api/v1/members") +public interface MemberV1ApiSpec { + + @Operation(summary = "회원가입", description = "새 회원을 등록합니다.") + ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..e71ec02d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +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/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberService memberService; + + @PostMapping("/signup") + @Override + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberService.signUp( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(MemberV1Dto.SignUpResponse.from(member)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..ca2906884 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank(message = "로그인ID는 필수입니다") + String loginId, + + @NotBlank(message = "비밀번호는 필수입니다") + String password, + + @NotBlank(message = "이름은 필수입니다") + String name, + + @NotBlank(message = "생년월일은 필수입니다") + String birthDate, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + String email + ) {} + + public record SignUpResponse(Long id, String loginId, String name, String birthDate, String email) { + public static SignUpResponse from(Member member) { + return new SignUpResponse( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..5d6a0e31f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Order V1 API", description = "주문 API") +@RequestMapping("/api/v1/orders") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "상품을 주문합니다.") + ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest request); +} 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..24cc504a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.interfaces.api.ApiResponse; +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; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderService orderService; + + @PostMapping + @Override + public ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest request) { + List items = request.items().stream() + .map(item -> new OrderDomainService.OrderLineRequest(item.productId(), item.quantity())) + .collect(Collectors.toList()); + OrderService.OrderResult result = orderService.placeOrder(request.memberId(), items); + return ApiResponse.success(OrderV1Dto.OrderCreateResponse.from(result)); + } +} 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..044235ee0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderV1Dto { + + public record OrderCreateRequest( + @NotNull(message = "회원 ID는 필수입니다") + Long memberId, + + @Valid + @NotNull(message = "주문 항목은 필수입니다") + List items + ) {} + + public record OrderLineRequest( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + + @NotNull(message = "수량은 필수입니다") + @Min(value = 1, message = "수량은 1 이상이어야 합니다") + Integer quantity + ) {} + + public record OrderCreateResponse( + Long orderId, + String status, + long totalAmount, + List orderLines + ) { + public static OrderCreateResponse from(OrderService.OrderResult result) { + List lines = result.orderLines().stream() + .map(ol -> new OrderLineResponse(ol.productId(), ol.quantity(), ol.unitPrice())) + .toList(); + return new OrderCreateResponse( + result.orderId(), + result.status(), + result.totalAmount(), + lines + ); + } + } + + public record OrderLineResponse(Long productId, int quantity, long unitPrice) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..6f2e0e75d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.SortCondition; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Product V1 API", description = "상품 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "정렬 조건에 따라 상품 목록을 조회합니다.") + ApiResponse> getProductList( + @Parameter(description = "정렬 조건: latest, price_asc, likes_desc") SortCondition sort + ); + + @Operation(summary = "상품 상세 조회", description = "ID로 상품 상세 정보를 조회합니다.") + ApiResponse getProductDetail(Long productId); +} 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..842a57626 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductListInfo; +import com.loopers.domain.product.SortCondition; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getProductList( + @RequestParam(defaultValue = "latest") SortCondition sort + ) { + List productList = productFacade.getProductList(sort); + List response = productList.stream() + .map(ProductV1Dto.ProductListResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProductDetail( + @PathVariable Long productId + ) { + ProductDetailInfo info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } +} 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..16a4b8a31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; + +public class ProductV1Dto { + + public record ProductDetailResponse( + Long id, + String name, + Long price, + int stockQuantity, + BrandResponse brand, + long likeCount + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + BrandResponse brandResponse = info.brand() != null + ? new BrandResponse(info.brand().id(), info.brand().name()) + : null; + return new ProductDetailResponse( + info.id(), + info.name(), + info.price(), + info.stockQuantity(), + brandResponse, + info.likeCount() + ); + } + } + + public record BrandResponse(Long id, String name) {} + + public record ProductListResponse( + Long id, + String name, + Long price, + String brandName, + long likeCount + ) { + public static ProductListResponse from(ProductListInfo info) { + return new ProductListResponse( + info.id(), + info.name(), + info.price(), + info.brandName(), + info.likeCount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..e32a6fc59 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java new file mode 100644 index 000000000..20b69ee00 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,170 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeServiceTest { + + private LikeService likeService; + private FakeLikeRepository fakeLikeRepository; + private FakeProductRepository fakeProductRepository; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + fakeProductRepository = new FakeProductRepository(); + likeService = new LikeService(fakeLikeRepository, fakeProductRepository); + } + + @DisplayName("좋아요 등록") + @Nested + class Like { + + @DisplayName("존재하는 상품에 좋아요를 등록하면 성공한다") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isTrue(); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요해도 멱등하게 동작한다") + @Test + void idempotent() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(1); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면 NOT_FOUND 예외가 발생한다") + @Test + void failsWhenProductNotFound() { + Long memberId = 1L; + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> likeService.like(memberId, nonExistentProductId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + @DisplayName("좋아요 취소") + @Nested + class Unlike { + + @DisplayName("좋아요한 상품의 좋아요를 취소하면 성공한다") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 10)); + Long productId = 1L; + fakeLikeRepository.save(new Like(memberId, productId)); + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isFalse(); + } + + @DisplayName("좋아요하지 않은 상품의 좋아요를 취소해도 예외 없이 동작한다") + @Test + void idempotent() { + Long memberId = 1L; + Long productId = 100L; + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(0); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public java.util.List findAll(com.loopers.domain.product.SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeLikeRepository implements LikeRepository { + private final List store = new ArrayList<>(); + private long id = 1; + + @Override + public Like save(Like like) { + Like toSave = new Like(like.getMemberId(), like.getProductId()); + store.add(toSave); + id++; + return toSave; + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + store.removeIf(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return store.stream() + .anyMatch(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public long countByProductId(Long productId) { + return store.stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + return productIds.stream() + .collect(java.util.stream.Collectors.toMap(id -> id, this::countByProductId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java new file mode 100644 index 000000000..607d88045 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,108 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderServiceTest { + + private OrderService orderService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + OrderDomainService orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + orderService = new OrderService(orderDomainService); + } + + @DisplayName("주문 생성") + @Nested + class PlaceOrder { + + @DisplayName("재고가 충분하면 주문이 성공한다") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 10)); + List items = List.of( + new OrderDomainService.OrderLineRequest(1L, 3) + ); + + OrderService.OrderResult result = orderService.placeOrder(memberId, items); + + assertThat(result.orderId()).isNotNull(); + assertThat(result.status()).isEqualTo("ORDERED"); + assertThat(result.totalAmount()).isEqualTo(30_000L); + assertThat(result.orderLines()).hasSize(1); + } + + @DisplayName("재고가 부족하면 예외가 발생한다") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 2)); + + assertThatThrownBy(() -> orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 5) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + long id = nextId++; + store.put(id, product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public com.loopers.domain.order.Order save(com.loopers.domain.order.Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + } +} 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..cc803461e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class LikeTest { + + @DisplayName("좋아요를 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 회원ID와 상품ID가 주어지면 성공한다") + @Test + void success() { + Long memberId = 1L; + Long productId = 100L; + + Like like = new Like(memberId, productId); + + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(memberId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("회원ID가 null이면 예외가 발생한다") + @Test + void failsWhenMemberIdIsNull() { + assertThatThrownBy(() -> new Like(null, 1L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("상품ID가 null이면 예외가 발생한다") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new Like(1L, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..88792c22b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,118 @@ +package com.loopers.domain.member; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원을 저장할 때") + @Nested + class Save { + + @DisplayName("유효한 회원 정보를 저장하면 성공한다") + @Test + void saveMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "조용민", + "1991-07-03", + "ymcho@example.com" + ); + + Member saved = memberRepository.save(member); + + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("ymcho"), + () -> assertThat(saved.getName()).isEqualTo("조용민") + ); + } + } + + @DisplayName("로그인ID로 회원을 조회할 때") + @Nested + class FindByLoginId { + + @DisplayName("존재하는 로그인ID로 조회하면 회원을 반환한다") + @Test + void findExistingMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "조용민", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + Optional found = memberRepository.findByLoginId("ymcho"); + + assertAll( + () -> assertThat(found).isPresent(), + () -> assertThat(found.get().getLoginId()).isEqualTo("ymcho"), + () -> assertThat(found.get().getName()).isEqualTo("조용민") + ); + } + + @DisplayName("존재하지 않는 로그인ID로 조회하면 빈 값을 반환한다") + @Test + void findNonExistingMember() { + Optional found = memberRepository.findByLoginId("nonexistent"); + + assertThat(found).isEmpty(); + } + } + + @DisplayName("로그인ID 중복을 확인할 때") + @Nested + class ExistsByLoginId { + + @DisplayName("이미 존재하는 로그인ID면 true를 반환한다") + @Test + void existingLoginId() { + Member member = new Member( + "ymcho", + "ymcho123", + "조용민", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + boolean exists = memberRepository.existsByLoginId("ymcho"); + + assertThat(exists).isTrue(); + } + + @DisplayName("존재하지 않는 로그인ID면 false를 반환한다") + @Test + void nonExistingLoginId() { + boolean exists = memberRepository.existsByLoginId("nonexistent"); + + assertThat(exists).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..8ec239ca7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberTest { + + @DisplayName("회원을 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면 성공한다") + @Test + void success() { + String loginId = "ymcho"; + String password = "ymcho123"; + String name = "조용민"; + String birthDate = "1991-07-03"; + String email = "ymcho@example.com"; + + Member member = new Member(loginId, password, name, birthDate, email); + + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("로그인ID가 null이면 예외가 발생한다") + @Test + void failsWhenLoginIdIsNull() { + assertThatThrownBy(() -> + new Member(null, "ymcho123", "조용민", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("로그인ID가 빈 문자열이면 예외가 발생한다") + @Test + void failsWhenLoginIdIsBlank() { + assertThatThrownBy(() -> + new Member(" ", "ymcho123", "조용민", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("비밀번호가 null이면 예외가 발생한다") + @Test + void failsWhenPasswordIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", null, "조용민", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("이름이 null이면 예외가 발생한다") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", null, "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("생년월일이 null이면 예외가 발생한다") + @Test + void failsWhenBirthDateIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "조용민", null, "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("이메일이 null이면 예외가 발생한다") + @Test + void failsWhenEmailIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "조용민", "1991-07-03", null) + ) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java new file mode 100644 index 000000000..6e761fc48 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderDomainServiceTest { + + private OrderDomainService orderDomainService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + } + + @DisplayName("주문 생성") + @Nested + class PlaceOrder { + + @DisplayName("재고가 충분하면 주문이 성공하고 재고가 차감된다") + @Test + void success() { + Long memberId = 1L; + Product product = fakeProductRepository.save(new Product(1L, "상품", 10_000L, 10)); + Long productId = 1L; + + Order order = orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 3) + )); + + assertThat(order.getMemberId()).isEqualTo(memberId); + assertThat(order.getOrderLines()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualTo(30_000L); + + Product updatedProduct = fakeProductRepository.findById(productId).orElseThrow(); + assertThat(updatedProduct.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 INSUFFICIENT_STOCK 예외가 발생하고 주문이 생성되지 않는다") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "상품", 10_000L, 5)); + Long productId = 1L; + + assertThatThrownBy(() -> orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 10) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + + assertThat(fakeOrderRepository.count()).isEqualTo(0); + } + + @DisplayName("존재하지 않는 상품이 포함되면 NOT_FOUND 예외가 발생한다") + @Test + void failsWhenProductNotFound() { + Long memberId = 1L; + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(nonExistentProductId, 1) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + Product product = store.get(id); + if (product == null) return Optional.empty(); + return Optional.of(product); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public Order save(Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + + int count() { + return store.size(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java new file mode 100644 index 000000000..cc80f4fe6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderLineTest { + + @DisplayName("OrderLine을 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 값이 주어지면 성공한다") + @Test + void success() { + OrderLine orderLine = new OrderLine(1L, 2, 10_000L); + + assertThat(orderLine.getTotalPrice()).isEqualTo(20_000L); + } + + @DisplayName("상품ID가 null이면 예외가 발생한다") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new OrderLine(null, 1, 10_000L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0 이하면 예외가 발생한다") + @Test + void failsWhenQuantityIsZeroOrNegative() { + assertThatThrownBy(() -> new OrderLine(1L, 0, 10_000L)) + .isInstanceOf(CoreException.class); + } + } +} 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..20fd0813d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderTest { + + @DisplayName("주문을 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 회원ID와 주문 항목이 주어지면 성공한다") + @Test + void success() { + Long memberId = 1L; + List orderLines = List.of( + new OrderLine(1L, 2, 10_000L), + new OrderLine(2L, 1, 5_000L) + ); + + Order order = Order.create(memberId, orderLines); + + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getStatus()).isEqualTo("ORDERED"), + () -> assertThat(order.getOrderLines()).hasSize(2), + () -> assertThat(order.getTotalAmount()).isEqualTo(25_000L) + ); + } + + @DisplayName("회원ID가 null이면 예외가 발생한다") + @Test + void failsWhenMemberIdIsNull() { + List orderLines = List.of(new OrderLine(1L, 1, 10_000L)); + + assertThatThrownBy(() -> Order.create(null, orderLines)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 비어있으면 예외가 발생한다") + @Test + void failsWhenOrderLinesIsEmpty() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java new file mode 100644 index 000000000..144e7e308 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java @@ -0,0 +1,47 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 이름이 주어지면 성공한다") + @Test + void success() { + String name = "나이키"; + + Brand brand = new Brand(name); + + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name) + ); + } + + @DisplayName("이름이 null이면 예외가 발생한다") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> new Brand(null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면 예외가 발생한다") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Brand(" ")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} 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..9e4a99085 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductTest { + + @DisplayName("상품을 생성할 때") + @Nested + class Create { + + @DisplayName("유효한 정보가 주어지면 성공한다") + @Test + void success() { + Long brandId = 1L; + String name = "에어맥스"; + Long price = 150_000L; + int stockQuantity = 10; + + Product product = new Product(brandId, name, price, stockQuantity); + + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getPrice()).isEqualTo(price), + () -> assertThat(product.getStockQuantity()).isEqualTo(stockQuantity) + ); + } + + @DisplayName("상품명이 비어있으면 예외가 발생한다") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Product(1L, " ", 1000L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 음수이면 예외가 발생한다") + @Test + void failsWhenPriceIsNegative() { + assertThatThrownBy(() -> new Product(1L, "상품", -1L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 음수이면 예외가 발생한다") + @Test + void failsWhenStockQuantityIsNegative() { + assertThatThrownBy(() -> new Product(1L, "상품", 1000L, -1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 차감할 때") + @Nested + class DecreaseStock { + + @DisplayName("재고가 충분하면 정상 차감된다") + @Test + void success() { + Product product = new Product(1L, "상품", 10_000L, 10); + + product.decreaseStock(3); + + assertThat(product.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("재고와 동일한 수량을 차감하면 0이 된다") + @Test + void successWhenExactStock() { + Product product = new Product(1L, "상품", 10_000L, 5); + + product.decreaseStock(5); + + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("재고가 부족하면 INSUFFICIENT_STOCK 예외가 발생한다") + @Test + void failsWhenInsufficientStock() { + Product product = new Product(1L, "상품", 10_000L, 5); + + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("차감 수량이 0이면 예외가 발생한다") + @Test + void failsWhenQuantityIsZero() { + Product product = new Product(1L, "상품", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(0)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("차감 수량이 음수이면 예외가 발생한다") + @Test + void failsWhenQuantityIsNegative() { + Product product = new Product(1L, "상품", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(-1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +}