diff --git a/.docs/Reason.md b/.docs/Reason.md new file mode 100644 index 000000000..6558ceabd --- /dev/null +++ b/.docs/Reason.md @@ -0,0 +1,270 @@ +# 설계 결정 근거 (Design Decision Rationale) + +본 문서는 Volume 3 구현 과정에서 내린 주요 설계 결정과 그 근거를 기록합니다. + +--- + +## 1. 패키지 구조: Aggregate별 하위 패키지 채택 + +### 결정 +`domain/model/` 아래 Aggregate별 하위 패키지로 분리 (`model/user/`, `model/product/`, `model/brand/`, `model/like/`, `model/order/`) + +### 근거 +- **process.md 원칙**: "패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태" +- **05-package-structure.md**: 평탄한 구조에 20~30개 파일이 쌓이면 탐색성과 응집도 저하 +- Aggregate 경계가 패키지로 표현되어 `import`만으로 소속을 파악 가능 +- 기존 레이어 구조(`application/`, `infrastructure/`, `interfaces/`)를 깨지 않음 +- 변경 범위가 `import` 문 수정에 한정 + +### 기각한 대안 +- `domain/` 자체를 Aggregate 단위로 분리 → 변경 범위가 너무 크고, 현재 규모(5 Aggregate)에 과도한 구조 + +--- + +## 2. Value Object 설계: Self-Validating + 정적 팩토리 + +### 결정 +모든 VO는 `private` 생성자 + `of()` 정적 팩토리 메서드, 생성 시점에 검증 수행 + +### 근거 +- **process.md 원칙**: "도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다" +- 기존 User 도메인의 `UserId.of()`, `Email.of()`, `Password.of()` 패턴과 일관성 유지 +- Bean Validation 제거 후 도메인 계층 검증으로 통일 (커밋 `4c17f62`) +- 유효하지 않은 상태의 객체가 존재할 수 없음 → "항상 유효한 도메인 모델" 보장 + +### 적용 예시 +```java +// Money.of(-1) → IllegalArgumentException +// Stock.of(-5) → IllegalArgumentException +// BrandName.of("") → IllegalArgumentException +``` + +--- + +## 3. 불변 도메인 객체: 상태 변경 시 새 인스턴스 반환 + +### 결정 +모든 Aggregate Root와 Entity의 상태 변경 메서드는 새 객체를 반환 (기존 객체 불변) + +### 근거 +- 기존 User 도메인의 패턴 답습: `User.updatePassword()` → 새 `User` 반환 +- 사이드 이펙트 방지: 한 참조를 수정해도 다른 참조에 영향 없음 +- 테스트 용이성: 입력과 출력이 명확하여 단위 테스트 작성이 단순 +- 동시성 안전: 불변 객체는 별도 동기화 없이 스레드 안전 + +### 적용 예시 +```java +Product updated = product.decreaseStock(3); // product는 변하지 않음 +productRepository.save(updated); // 새 인스턴스를 저장 +``` + +--- + +## 4. Aggregate 간 ID 참조 + +### 결정 +Aggregate 간에는 직접 참조 대신 ID(Long) 참조 사용. 단, 타입 안전한 식별자(`UserId`)는 해당 Aggregate 패키지에서 import + +### 근거 +- **03-class-diagram.md**: "Aggregate 간 ID 참조" 원칙 +- **05-package-structure.md**: "UserId는 user/ 패키지에 그대로 둔다. 다른 Aggregate가 import해서 사용" +- Aggregate 간 결합도 최소화 → 각 Aggregate를 독립적으로 변경 가능 +- JPA 레벨에서 Lazy Loading 이슈 원천 차단 + +### 적용 +| 도메인 | 참조 방식 | +|--------|----------| +| `Product.brandId` | `Long` (Brand Aggregate와 느슨한 결합) | +| `Like.userId` | `UserId` (타입 안전한 ID 참조) | +| `Like.productId` | `Long` | +| `Order.userId` | `UserId` | +| `OrderItem.productId` | `Long` | + +--- + +## 5. Soft Delete 패턴 + +### 결정 +Brand, Product에 `deletedAt` 필드를 두어 논리적 삭제 수행 + +### 근거 +- **01-requirements.md**: 상품/브랜드 삭제 시 기존 주문 데이터의 참조 무결성 유지 필요 +- 물리적 삭제 시 주문 내역에서 "삭제된 상품" 표시 불가 +- 조회 시 `isDeleted()` / `filter(p -> !p.isDeleted())` 로 간단히 필터링 +- 향후 데이터 복구, 감사 로그 활용 가능 + +--- + +## 6. 비즈니스 로직 위치: 도메인 객체 vs Application Service + +### 결정 +단일 Aggregate 내 규칙은 도메인 객체에, 여러 Aggregate 협력은 Application Service에 배치 + +### 근거 +- **process.md 원칙**: "규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다" +- **process.md 원칙**: "애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공" + +### 구체적 배치 + +| 로직 | 위치 | 이유 | +|------|------|------| +| `Stock.decrease()` | Domain (VO) | 재고 차감은 Stock 자체의 규칙 | +| `Order.isCancellable()` | Domain (AR) | 상태 전이 규칙은 Order 자체의 불변식 | +| `Money.add/subtract` | Domain (VO) | 금액 연산은 Money 자체의 규칙 | +| Like 생성 + Product.likeCount 증가 | Application (LikeService) | 두 Aggregate(Like, Product) 협력 | +| 재고 차감 + 주문 생성 | Application (OrderService) | Product 재고차감 + Order 생성 협력 | +| Product + Brand 조합 조회 | Application (ProductQueryService) | 두 Aggregate 정보 조합 | + +--- + +## 7. Command/Query Service 분리 + +### 결정 +Product, Order 도메인은 Command Service와 Query Service를 분리 + +### 근거 +- **03-class-diagram.md**: 설계 문서에서 CUD와 R 서비스를 분리 명시 +- Command와 Query의 트랜잭션 특성이 다름 (`@Transactional` vs `@Transactional(readOnly = true)`) +- Query Service는 여러 Aggregate를 조합하여 읽기 전용 DTO를 반환 → Command와 관심사가 다름 +- Brand, Like는 규모가 작아 통합 Service로 유지 (과도한 분리 방지) + +| 도메인 | Command | Query | 분리 이유 | +|--------|---------|-------|----------| +| Brand | `BrandService` | (통합) | CRUD가 단순, 조합 조회 없음 | +| Product | `ProductService` | `ProductQueryService` | 상세 조회 시 Brand 정보 조합 필요 | +| Like | `LikeService` | (통합) | 조회 UseCase가 현재 없음 | +| Order | `OrderService` | `OrderQueryService` | 주문 생성(복잡한 트랜잭션) vs 조회(읽기 전용) | + +--- + +## 8. UseCase 인터페이스 패턴 + +### 결정 +각 유스케이스를 독립 인터페이스로 정의, Service가 필요한 UseCase를 구현 + +### 근거 +- 기존 User 도메인의 `RegisterUseCase`, `AuthenticationUseCase` 패턴 답습 +- **ISP (Interface Segregation Principle)**: Controller는 자신이 사용하는 UseCase만 의존 +- DIP 준수: Interfaces 레이어 → Application 레이어의 인터페이스에 의존 +- 테스트 시 필요한 UseCase만 Stub/Mock 가능 + +### 적용 +```java +// Controller는 필요한 UseCase만 의존 +public class ProductController { + private final CreateProductUseCase createProductUseCase; + private final ProductQueryUseCase productQueryUseCase; + // DeleteProductUseCase는 주입받지 않음 → 불필요한 의존 제거 +} +``` + +--- + +## 9. 주문 시점 가격 스냅샷 + +### 결정 +`OrderItem.unitPrice`에 주문 시점의 상품 가격을 저장, `OrderSnapshot`에 상품명:가격 형태로 기록 + +### 근거 +- **01-requirements.md**: 주문 시점의 가격이 보존되어야 함 +- 상품 가격이 변경되어도 기존 주문의 결제 금액에 영향 없음 +- 주문 상세 조회 시 주문 당시 가격 표시 가능 +- **04-erd.md**: `order_items.unit_price` 컬럼으로 스냅샷 가격 저장 + +--- + +## 10. 에러 메시지: 도메인 객체 내부 배치 + +### 결정 +각 도메인 객체의 검증 실패 메시지를 해당 객체 내부에 한국어로 직접 배치 + +### 근거 +- **YAGNI 원칙**: 현재 다국어 지원 요구사항 없음, 에러 메시지 중앙화의 실익 없음 +- 응집도: 검증 규칙과 에러 메시지가 같은 위치에 있어 수정 시 한 파일만 변경 +- 기존 User 도메인 패턴 답습: `UserId`, `Email`, `Password` 등 모두 내부에 메시지 보유 +- 향후 다국어/중앙화 필요 시 MessageSource 도입으로 마이그레이션 가능 + +--- + +## 11. Like 멱등성 (Idempotency) + +### 결정 +이미 좋아요한 상태에서 `like()` 호출 시 예외 대신 무시 (early return) + +### 근거 +- **01-requirements.md**: 중복 좋아요 방지 +- 네트워크 재시도, 프론트엔드 더블클릭 등 실무에서 중복 호출 빈번 +- 예외 발생 시 불필요한 에러 로그, 클라이언트 에러 핸들링 부담 +- `unlike()` 도 동일하게 멱등적 처리: 좋아요하지 않은 상태에서 호출 시 무시 + +--- + +## 12. Order.create()에서 totalAmount 자동 계산 + +### 결정 +`Order.create()` 내부에서 `OrderItem` 목록으로부터 `totalAmount`를 자동 계산 + +### 근거 +- 외부에서 totalAmount를 전달받으면 조작/불일치 가능성 존재 +- 도메인 불변식: `totalAmount = SUM(item.unitPrice * item.quantity)` 는 Order의 핵심 규칙 +- `paymentAmount = totalAmount - discountAmount` 도 내부에서 계산하여 정합성 보장 +- **process.md**: "도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다" + +--- + +## 13. Admin/User Interceptor 분리 + +### 결정 +`AuthenticationInterceptor`(User)와 `AdminAuthenticationInterceptor`(Admin)를 별도 컴포넌트로 구현 + +### 근거 +- **01-requirements.md 2.2절**: Admin(`X-Loopers-Ldap`)과 User(`X-Loopers-LoginId` + `X-Loopers-LoginPw`)는 완전히 다른 인증 체계 +- **06-admin-authentication.md**: Admin은 DB 조회 없이 헤더 값 일치만 확인, User 테이블 변경 불필요 +- 단일 책임 원칙: 각 Interceptor가 하나의 인증 방식만 담당 +- `WebMvcConfig`에서 경로 패턴으로 분리 등록: `/api/v1/**` → User, `/api-admin/v1/**` → Admin + +--- + +## 14. Controller별 역할 분리 (Admin vs User) + +### 결정 +같은 도메인이라도 Admin Controller와 User Controller를 분리 + +### 근거 +- **03-class-diagram Part E~H**: Brand, Product, Order 모두 Admin/User Controller 분리 설계 +- Admin은 CRUD 전체 접근, User는 조회만 접근 → 하나의 Controller에 혼재 시 인증 경로 관리 복잡 +- 엔드포인트 경로가 다름: `/api-admin/v1/brands` vs `/api/v1/brands` +- 각 Controller가 필요한 UseCase만 의존하여 결합도 최소화 + +| 도메인 | Admin Controller | User Controller | +|--------|-----------------|-----------------| +| Brand | `BrandAdminController` (CRUD) | `BrandController` (조회) | +| Product | `ProductAdminController` (CUD+조회) | `ProductController` (조회) | +| Like | - | `LikeController` (등록/취소) | +| Order | - | `OrderController` (생성/조회) | + +--- + +## 15. Interfaces DTO와 Application DTO 분리 + +### 결정 +Request/Response DTO를 `interfaces/api/dto/`에 별도 정의, Application 레이어의 record와 `from()` 메서드로 변환 + +### 근거 +- **process.md**: "API request, response DTO와 응용 레이어의 DTO는 분리해 작성" +- Interfaces 레이어 변경(필드 추가/제거, 포맷 변경)이 Application 레이어에 전파되지 않음 +- 기존 `UserInfoResponse.from(UserQueryUseCase.UserInfoResponse)` 패턴 답습 +- `OrderCreateRequest.toCommand()`: DTO → Application Command 변환을 DTO 자체에 캡슐화 + +--- + +## 16. Infrastructure Layer: BaseEntity 미상속 + +### 결정 +새로운 JPA Entity들이 `BaseEntity`를 상속하지 않고 자체 필드로 관리 + +### 근거 +- 기존 `UserJpaEntity` 패턴 답습: 프로젝트 내 일관성 유지 +- `BaseEntity`는 `ZonedDateTime` 사용, 도메인 모델은 `LocalDateTime` 사용 → 타입 불일치 +- Like, OrderItem 등 `updated_at`/`deleted_at`가 불필요한 엔티티에 불필요한 컬럼 생성 방지 +- 각 Entity가 자신에게 필요한 필드만 정확히 가짐 → 명시적이고 예측 가능 diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index 8bc92d09d..2009d080d 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -48,9 +48,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Guest | `POST` | `/users/register` | **회원가입** | ID 중복 체크 필수 | -| User | `GET` | `/users/me` | **내 정보 조회** | 이름 마스킹 처리 | -| User | `PUT` | `/users/me/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | +| Guest | `POST` | `/api/v1/users` | **회원가입** | ID 중복 체크 필수 | +| User | `GET` | `/api/v1/users/me` | **내 정보 조회** | 이름 마스킹 처리 | +| User | `PUT` | `/api/v1/users/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | #### 상세 요구사항 * **회원가입 입력값**: ID, PW, 이름, 생년월일, 이메일 @@ -67,9 +67,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Any | `GET` | `/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | -| Any | `GET` | `/products` | **상품 목록** | 필터, 정렬, 페이징 | -| Any | `GET` | `/products/{productId}` | **상품 상세** | | +| Any | `GET` | `/api/v1/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | +| Any | `GET` | `/api/v1/products` | **상품 목록** | 필터, 정렬, 페이징 | +| Any | `GET` | `/api/v1/products/{productId}` | **상품 상세** | | #### 상세 요구사항 * **목록 조회 쿼리 파라미터**: @@ -83,9 +83,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | -| User | `DELETE` | `/products/{id}/likes` | **좋아요 취소** | | -| User | `GET` | `/users/me/likes` | **좋아요 목록** | 필터링 지원 | +| User | `POST` | `/api/v1/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | +| User | `DELETE` | `/api/v1/products/{id}/likes` | **좋아요 취소** | | +| User | `GET` | `/api/v1/users/{userId}/likes` | **좋아요 목록** | 필터링 지원 | #### 상세 요구사항 * **제약**: 유저당 1개의 상품에 1번만 좋아요 가능. @@ -99,9 +99,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/orders` | **주문 요청** | 트랜잭션 처리 필수 | -| User | `GET` | `/orders/me` | **내 주문 목록** | 기간 조회 | -| User | `GET` | `/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | +| User | `POST` | `/api/v1/orders` | **주문 요청** | 트랜잭션 처리 필수 | +| User | `GET` | `/api/v1/orders` | **내 주문 목록** | `startAt`, `endAt` 기간 필터 | +| User | `GET` | `/api/v1/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | #### 상세 요구사항 1. **주문 요청**: @@ -122,13 +122,17 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | | Admin | `GET` | `/api-admin/v1/brands` | 브랜드 목록 | | +| Admin | `GET` | `/api-admin/v1/brands/{brandId}` | 브랜드 상세 조회 | | | Admin | `POST` | `/api-admin/v1/brands` | 브랜드 등록 | | | Admin | `PUT` | `/api-admin/v1/brands/{id}` | 브랜드 수정 | | | Admin | `DELETE`| `/api-admin/v1/brands/{id}` | **브랜드 삭제** | **[Cascade]** 하위 상품 일괄 삭제 | +| Admin | `GET` | `/api-admin/v1/products` | **상품 목록 조회** | 페이징, `brandId` 필터 | +| Admin | `GET` | `/api-admin/v1/products/{productId}` | **상품 상세 조회** | | | Admin | `POST` | `/api-admin/v1/products` | **상품 등록** | 등록된 브랜드 ID만 허용 | | Admin | `PUT` | `/api-admin/v1/products/{id}`| **상품 수정** | **[Immutable]** 브랜드 변경 불가 | | Admin | `DELETE`| `/api-admin/v1/products/{id}`| 상품 삭제 | Soft Delete 권장 | | Admin | `GET` | `/api-admin/v1/orders` | 주문 목록 | 전체 유저 주문 조회 | +| Admin | `GET` | `/api-admin/v1/orders/{orderId}` | 주문 상세 조회 | | --- @@ -158,7 +162,6 @@ | 코드 | HTTP 상태 | 설명 | | :--- | :---: | :--- | | `BAD_REQUEST` | 400 | 유효성 검사 실패, 인증 실패, ID 중복 등 | -| `VALIDATION_ERROR` | 400 | DTO `@Valid` 어노테이션 검증 실패 | | `MISSING_HEADER` | 400 | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | | `Not Found` | 404 | 존재하지 않는 리소스 | | `Conflict` | 409 | 비즈니스 로직 충돌 (리소스 중복 등) | diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index a7b525697..60f901868 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -39,7 +39,7 @@ sequenceDiagram participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository - User->>API: POST /api/v1/users/register (loginId, password, name, birthday, email) + User->>API: POST /api/v1/users (loginId, password, name, birthday, email) API->>Service: register(loginId, name, rawPassword, birthday, email) rect rgb(240, 248, 255) @@ -150,7 +150,7 @@ sequenceDiagram participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository - User->>API: PUT /api/v1/users/me/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) + User->>API: PUT /api/v1/users/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) rect rgb(255, 230, 230) Note right of Interceptor: [책임 1] Interceptor preHandle — 헤더 기반 인증 diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index a04288679..b6d38a7c3 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -131,7 +131,6 @@ classDiagram -UserRepository userRepository -PasswordEncoder passwordEncoder +authenticate(UserId, String) void - -findUser(UserId) User } %% --- Interceptor → UseCase --- @@ -183,7 +182,7 @@ classDiagram - **인증 관심사 분리**: `AuthenticationInterceptor`가 `/api/v1/users/me/**` 경로의 인증을 전담한다. Controller는 `AuthenticationUseCase`를 더 이상 알지 못하며, `HttpServletRequest`의 `authenticatedUserId` 속성에서 인증된 사용자를 꺼내 쓴다. - **Service 분리**: `UserService`는 Register, Query, PasswordUpdate만 구현하고, `AuthenticationService`가 인증만 전담한다. 향후 도메인(주문, 좋아요 등)이 추가되어도 각 도메인별 Service가 독립적으로 존재하는 패턴의 기반이 된다. -- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `/api/v1/users/register`는 인증 없이 접근 가능하다. +- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `POST /api/v1/users` (회원가입)는 인증 없이 접근 가능하다. ### 설계 의도 @@ -625,7 +624,6 @@ classDiagram <> +handleCoreException(CoreException) ResponseEntity +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity - +handleValidationException(MethodArgumentNotValidException) ResponseEntity +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity +handleException(Exception) ResponseEntity } @@ -764,6 +762,7 @@ classDiagram +updateBrand(Long, BrandUpdateRequest) ResponseEntity +deleteBrand(Long) ResponseEntity +getBrands() ResponseEntity + +getBrand(Long) ResponseEntity } class BrandController { <> @@ -888,9 +887,12 @@ classDiagram -CreateProductUseCase createProductUseCase -UpdateProductUseCase updateProductUseCase -DeleteProductUseCase deleteProductUseCase + -ProductQueryUseCase productQueryUseCase +createProduct(ProductCreateRequest) ResponseEntity +updateProduct(Long, ProductUpdateRequest) ResponseEntity +deleteProduct(Long) ResponseEntity + +getProducts(ProductSearchCondition) ResponseEntity + +getProduct(Long) ResponseEntity } class ProductController { <> @@ -934,6 +936,7 @@ classDiagram ProductAdminController ..> CreateProductUseCase : uses ProductAdminController ..> UpdateProductUseCase : uses ProductAdminController ..> DeleteProductUseCase : uses + ProductAdminController ..> ProductQueryUseCase : uses ProductController ..> ProductQueryUseCase : uses ProductService ..|> CreateProductUseCase : implements @@ -1149,6 +1152,7 @@ classDiagram <> -OrderQueryUseCase orderQueryUseCase +getAllOrders(OrderSearchCondition) ResponseEntity + +getOrder(Long) ResponseEntity } class CreateOrderUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java deleted file mode 100644 index 2a9a803e5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.application; - -import java.time.LocalDate; - -public interface RegisterUseCase { - - void register(String loginId, String name, String rawPassword, LocalDate birthday, String email); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java new file mode 100644 index 000000000..0d7cd676c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java @@ -0,0 +1,29 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.event.BrandDeletedEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class BrandDeletedEventHandler { + + private final ProductRepository productRepository; + + public BrandDeletedEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @EventListener + public void handle(BrandDeletedEvent event) { + List products = productRepository.findAllByBrandId(event.brandId()); + for (Product product : products) { + if (!product.isDeleted()) { + productRepository.save(product.delete()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java new file mode 100644 index 000000000..b5bd568a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java @@ -0,0 +1,37 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.repository.BrandRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class BrandQueryService implements BrandQueryUseCase { + + private final BrandRepository brandRepository; + + public BrandQueryService(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + public BrandInfo getBrand(Long brandId) { + Brand brand = brandRepository.findActiveById(brandId) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + return toBrandInfo(brand); + } + + @Override + public List getBrands() { + return brandRepository.findAllActive().stream() + .map(this::toBrandInfo) + .toList(); + } + + private BrandInfo toBrandInfo(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName().getValue(), brand.getDescription()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java new file mode 100644 index 000000000..99c63e548 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java @@ -0,0 +1,16 @@ +package com.loopers.application.brand; + +import java.util.List; + +public interface BrandQueryUseCase { + + BrandInfo getBrand(Long brandId); + + List getBrands(); + + record BrandInfo( + Long id, + String name, + String description + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..47f67911e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,51 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.common.DomainEventPublisher; +import com.loopers.domain.repository.BrandRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase { + + private final BrandRepository brandRepository; + private final DomainEventPublisher eventPublisher; + + public BrandService(BrandRepository brandRepository, DomainEventPublisher eventPublisher) { + this.brandRepository = brandRepository; + this.eventPublisher = eventPublisher; + } + + @Override + public void createBrand(String name, String description) { + BrandName brandName = BrandName.of(name); + if (brandRepository.existsByName(brandName)) { + throw new IllegalArgumentException("이미 존재하는 브랜드 이름입니다."); + } + Brand brand = Brand.create(brandName, description); + brandRepository.save(brand); + } + + @Override + public void updateBrand(Long brandId, String name, String description) { + Brand brand = findBrand(brandId); + Brand updated = brand.update(BrandName.of(name), description); + brandRepository.save(updated); + } + + @Override + public void deleteBrand(Long brandId) { + Brand brand = findBrand(brandId); + Brand deleted = brand.delete(); + brandRepository.save(deleted); + eventPublisher.publishEvents(deleted); + } + + private Brand findBrand(Long brandId) { + return brandRepository.findActiveById(brandId) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java new file mode 100644 index 000000000..cc0e7bc09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application.brand; + +public interface CreateBrandUseCase { + + void createBrand(String name, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java new file mode 100644 index 000000000..ebd3872b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application.brand; + +public interface DeleteBrandUseCase { + + void deleteBrand(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java new file mode 100644 index 000000000..5a0eb55cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application.brand; + +public interface UpdateBrandUseCase { + + void updateBrand(Long brandId, String name, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java new file mode 100644 index 000000000..0ba0d8497 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java @@ -0,0 +1,34 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.like.event.ProductLikedEvent; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class LikeEventHandler { + + private final ProductRepository productRepository; + + public LikeEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @EventListener + public void handle(ProductLikedEvent event) { + Product product = productRepository.findById(event.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product updated = product.increaseLikeCount(); + productRepository.save(updated); + } + + @EventListener + public void handle(ProductUnlikedEvent event) { + Product product = productRepository.findById(event.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product updated = product.decreaseLikeCount(); + productRepository.save(updated); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java new file mode 100644 index 000000000..0250d851b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public interface LikeProductReadPort { + + List findLikedProductsByUserId(UserId userId); + + record LikeProductView( + Long productId, + String productName, + int price, + Integer salePrice, + int stockQuantity, + String brandName, + LocalDateTime likedAt + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java new file mode 100644 index 000000000..1e7920159 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.application.like.LikeProductReadPort.LikeProductView; +import com.loopers.domain.model.product.ProductPricing; +import com.loopers.domain.model.user.UserId; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +@Service +@Transactional(readOnly = true) +public class LikeQueryService implements LikeQueryUseCase { + + private final LikeProductReadPort likeProductReadPort; + + public LikeQueryService(LikeProductReadPort likeProductReadPort) { + this.likeProductReadPort = likeProductReadPort; + } + + @Override + public List getMyLikes(UserId userId, String sort, Boolean saleYn, String status) { + List likes = likeProductReadPort.findLikedProductsByUserId(userId); + + Stream stream = likes.stream() + .map(lp -> { + boolean onSale = lp.salePrice() != null; + int discountRate = ProductPricing.calculateDiscountRate(lp.price(), lp.salePrice()); + boolean soldOut = lp.stockQuantity() == 0; + return new LikeInfo( + lp.productId(), lp.productName(), lp.price(), lp.salePrice(), + onSale, discountRate, lp.brandName(), soldOut, lp.likedAt() + ); + }); + + if (Boolean.TRUE.equals(saleYn)) { + stream = stream.filter(LikeInfo::onSale); + } + if ("selling".equals(status)) { + stream = stream.filter(info -> !info.soldOut()); + } + + Comparator comparator = switch (sort != null ? sort : "latest") { + case "price_asc" -> Comparator.comparingInt(LikeInfo::price); + case "discount_rate_desc" -> Comparator.comparingInt(LikeInfo::discountRate).reversed(); + case "brand_name_asc" -> Comparator.comparing(LikeInfo::brandName); + default -> Comparator.comparing(LikeInfo::likedAt).reversed(); + }; + + return stream.sorted(comparator).toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java new file mode 100644 index 000000000..967a0ce1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java @@ -0,0 +1,23 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public interface LikeQueryUseCase { + + List getMyLikes(UserId userId, String sort, Boolean saleYn, String status); + + record LikeInfo( + Long productId, + String productName, + int price, + Integer salePrice, + boolean onSale, + int discountRate, + String brandName, + boolean soldOut, + LocalDateTime likedAt + ) {} +} 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..3f9a5558f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,56 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.common.DomainEventPublisher; +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class LikeService implements LikeUseCase, UnlikeUseCase { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + private final DomainEventPublisher domainEventPublisher; + + public LikeService(LikeRepository likeRepository, ProductRepository productRepository, + DomainEventPublisher domainEventPublisher) { + this.likeRepository = likeRepository; + this.productRepository = productRepository; + this.domainEventPublisher = domainEventPublisher; + } + + @Override + public void like(UserId userId, Long productId) { + findProduct(productId); + + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + return; + } + + Like like = Like.create(userId, productId); + likeRepository.save(like); + domainEventPublisher.publishEvents(like); + } + + @Override + public void unlike(UserId userId, Long productId) { + findProduct(productId); + + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(like -> { + Like unliked = like.markUnliked(); + domainEventPublisher.publishEvents(unliked); + likeRepository.deleteByUserIdAndProductId(userId, productId); + }); + } + + private Product findProduct(Long productId) { + return productRepository.findActiveById(productId) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java new file mode 100644 index 000000000..ebec687cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +public interface LikeUseCase { + + void like(UserId userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java new file mode 100644 index 000000000..b95381919 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +public interface UnlikeUseCase { + + void unlike(UserId userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java new file mode 100644 index 000000000..a7115aef0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +public interface CancelOrderUseCase { + + void cancelOrder(UserId userId, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java new file mode 100644 index 000000000..18f3e2e0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java @@ -0,0 +1,25 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDate; +import java.util.List; + +public interface CreateOrderUseCase { + + void createOrder(UserId userId, OrderCommand command); + + record OrderCommand( + List items, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + LocalDate desiredDeliveryDate + ) {} + + record OrderItemCommand( + Long productId, + int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java new file mode 100644 index 000000000..3fc997653 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java @@ -0,0 +1,27 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.event.OrderCancelledEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class OrderCancelledEventHandler { + + private final ProductRepository productRepository; + + public OrderCancelledEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @EventListener + public void handle(OrderCancelledEvent event) { + for (OrderCancelledEvent.CancelledItem item : event.cancelledItems()) { + Product product = productRepository.findById(item.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product restored = product.increaseStock(item.quantity()); + productRepository.save(restored); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java new file mode 100644 index 000000000..319f13d6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java @@ -0,0 +1,99 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.Order; +import com.loopers.domain.model.order.OrderItem; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class OrderQueryService implements OrderQueryUseCase { + + private final OrderRepository orderRepository; + + public OrderQueryService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + @Override + public List getMyOrders(UserId userId) { + List orders = orderRepository.findAllByUserId(userId); + return toSummaries(orders); + } + + @Override + public List getMyOrders(UserId userId, LocalDate startAt, LocalDate endAt) { + List orders = orderRepository.findAllByUserIdAndDateRange( + userId, + startAt.atStartOfDay(), + endAt.atTime(LocalTime.MAX) + ); + return toSummaries(orders); + } + + @Override + public List getAllOrders() { + List orders = orderRepository.findAll(); + return toSummaries(orders); + } + + @Override + public OrderDetail getOrderDetail(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + return toOrderDetail(order); + } + + @Override + public OrderDetail getOrder(UserId userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + return toOrderDetail(order); + } + + private List toSummaries(List orders) { + return orders.stream() + .map(order -> new OrderSummary( + order.getId(), + order.getStatus().name(), + order.getPaymentAmount().getValue(), + order.getCreatedAt() + )) + .toList(); + } + + private OrderDetail toOrderDetail(Order order) { + List itemDetails = order.getItems().stream() + .map(this::toOrderItemDetail) + .toList(); + + return new OrderDetail( + order.getId(), + order.getReceiverName(), + order.getAddress(), + order.getDeliveryRequest(), + order.getPaymentMethod().name(), + order.getTotalAmount().getValue(), + order.getDiscountAmount().getValue(), + order.getPaymentAmount().getValue(), + order.getStatus().name(), + itemDetails, + order.getCreatedAt() + ); + } + + private OrderItemDetail toOrderItemDetail(OrderItem item) { + return new OrderItemDetail( + item.getProductId(), + item.getQuantity(), + item.getUnitPrice().getValue() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java new file mode 100644 index 000000000..fadc9e0c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java @@ -0,0 +1,47 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface OrderQueryUseCase { + + List getMyOrders(UserId userId); + + List getMyOrders(UserId userId, LocalDate startAt, LocalDate endAt); + + List getAllOrders(); + + OrderDetail getOrderDetail(Long orderId); + + OrderDetail getOrder(UserId userId, Long orderId); + + record OrderSummary( + Long id, + String status, + int paymentAmount, + LocalDateTime createdAt + ) {} + + record OrderDetail( + Long id, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + int totalAmount, + int discountAmount, + int paymentAmount, + String status, + List items, + LocalDateTime createdAt + ) {} + + record OrderItemDetail( + Long productId, + int quantity, + int unitPrice + ) {} +} 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..0613271be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,81 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.common.DomainEventPublisher; +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +public class OrderService implements CreateOrderUseCase, CancelOrderUseCase, UpdateDeliveryAddressUseCase { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final DomainEventPublisher eventPublisher; + + public OrderService(OrderRepository orderRepository, ProductRepository productRepository, + DomainEventPublisher eventPublisher) { + this.orderRepository = orderRepository; + this.productRepository = productRepository; + this.eventPublisher = eventPublisher; + } + + @Override + public void createOrder(UserId userId, OrderCommand command) { + List orderLines = command.items().stream() + .map(itemCommand -> { + Product product = productRepository.findActiveByIdWithLock(itemCommand.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. ID: " + itemCommand.productId())); + + Product decreased = product.decreaseStock(itemCommand.quantity()); + productRepository.save(decreased); + + return new OrderLine( + product.getId(), + product.getName().getValue(), + Money.of(product.getPrice().getValue()), + itemCommand.quantity() + ); + }) + .toList(); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + command.receiverName(), + command.address(), + command.deliveryRequest(), + command.desiredDeliveryDate() + ); + + PaymentMethod paymentMethod = PaymentMethod.valueOf(command.paymentMethod()); + Order order = Order.create(userId, orderLines, deliveryInfo, paymentMethod, Money.zero()); + + orderRepository.save(order); + } + + @Override + public void cancelOrder(UserId userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + + Order cancelled = order.cancel(); + orderRepository.save(cancelled); + eventPublisher.publishEvents(cancelled); + } + + @Override + public void updateDeliveryAddress(UserId userId, Long orderId, String newAddress) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + + Order updated = order.updateDeliveryAddress(newAddress); + orderRepository.save(updated); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java new file mode 100644 index 000000000..a01ed6734 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +public interface UpdateDeliveryAddressUseCase { + + void updateDeliveryAddress(UserId userId, Long orderId, String newAddress); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java new file mode 100644 index 000000000..9265bd4f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java @@ -0,0 +1,11 @@ +package com.loopers.application.product; + +public interface CreateProductUseCase { + + void createProduct(ProductCreateCommand command); + + record ProductCreateCommand( + Long brandId, String name, int price, + Integer salePrice, int stock, String description + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java new file mode 100644 index 000000000..703e2626d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application.product; + +public interface DeleteProductUseCase { + + void deleteProduct(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java new file mode 100644 index 000000000..e8faae10b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -0,0 +1,72 @@ +package com.loopers.application.product; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class ProductQueryService implements ProductQueryUseCase { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public ProductQueryService(ProductRepository productRepository, BrandRepository brandRepository) { + this.productRepository = productRepository; + this.brandRepository = brandRepository; + } + + @Override + public ProductDetailInfo getProduct(Long productId) { + Product product = productRepository.findActiveById(productId) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + + return new ProductDetailInfo( + product.getId(), + brand.getId(), + brand.getName().getValue(), + product.getName().getValue(), + product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, + product.isOnSale(), + product.getStock().getValue(), + product.getLikeCount(), + product.getDescription() + ); + } + + @Override + public PageResult getProducts(Long brandId, String sort, int page, int size) { + PageResult products = productRepository.findAllActive(brandId, sort, page, size); + + List brandIds = products.content().stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandNameMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b.getName().getValue())); + + return products.map(product -> new ProductSummaryInfo( + product.getId(), + product.getBrandId(), + brandNameMap.getOrDefault(product.getBrandId(), ""), + product.getName().getValue(), + product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, + product.isOnSale(), + product.getLikeCount() + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java new file mode 100644 index 000000000..bc6c32b07 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java @@ -0,0 +1,34 @@ +package com.loopers.application.product; + +import com.loopers.domain.model.common.PageResult; + +public interface ProductQueryUseCase { + + ProductDetailInfo getProduct(Long productId); + + PageResult getProducts(Long brandId, String sort, int page, int size); + + record ProductDetailInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + Integer salePrice, + boolean onSale, + int stock, + int likeCount, + String description + ) {} + + record ProductSummaryInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + Integer salePrice, + boolean onSale, + int likeCount + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..fd32c1c03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,58 @@ +package com.loopers.application.product; + +import com.loopers.application.product.CreateProductUseCase; +import com.loopers.application.product.DeleteProductUseCase; +import com.loopers.application.product.UpdateProductUseCase; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class ProductService implements CreateProductUseCase, UpdateProductUseCase, DeleteProductUseCase { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public ProductService(ProductRepository productRepository, BrandRepository brandRepository) { + this.productRepository = productRepository; + this.brandRepository = brandRepository; + } + + @Override + public void createProduct(ProductCreateCommand command) { + brandRepository.findActiveById(command.brandId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 브랜드입니다.")); + + Price salePriceVo = command.salePrice() != null ? Price.of(command.salePrice()) : null; + Product product = Product.create(command.brandId(), ProductName.of(command.name()), + Price.of(command.price()), salePriceVo, Stock.of(command.stock()), command.description()); + productRepository.save(product); + } + + @Override + public void updateProduct(ProductUpdateCommand command) { + Product product = findProduct(command.productId()); + Price salePriceVo = command.salePrice() != null ? Price.of(command.salePrice()) : null; + Product updated = product.update(ProductName.of(command.name()), Price.of(command.price()), + salePriceVo, Stock.of(command.stock()), command.description()); + productRepository.save(updated); + } + + @Override + public void deleteProduct(Long productId) { + Product product = findProduct(productId); + Product deleted = product.delete(); + productRepository.save(deleted); + } + + private Product findProduct(Long productId) { + return productRepository.findActiveById(productId) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java new file mode 100644 index 000000000..21dd42b0c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java @@ -0,0 +1,11 @@ +package com.loopers.application.product; + +public interface UpdateProductUseCase { + + void updateProduct(ProductUpdateCommand command); + + record ProductUpdateCommand( + Long productId, String name, int price, + Integer salePrice, int stock, String description + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java similarity index 61% rename from apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java index f2f6fa6f6..8cc570431 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java @@ -1,8 +1,8 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; +import com.loopers.application.user.AuthenticationUseCase; +import com.loopers.domain.model.user.User; +import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.springframework.stereotype.Service; @@ -19,18 +19,15 @@ public AuthenticationService(UserRepository userRepository, PasswordEncoder pass this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } + private static final String AUTH_FAILURE_MESSAGE = "아이디 또는 비밀번호가 올바르지 않습니다."; @Override public void authenticate(UserId userId, String rawPassword) { - User user = findUser(userId); + User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException(AUTH_FAILURE_MESSAGE)); if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new IllegalArgumentException(AUTH_FAILURE_MESSAGE); } } - private User findUser(UserId userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java similarity index 55% rename from apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java index 3d274cd6f..7a566f51b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java @@ -1,6 +1,6 @@ -package com.loopers.application; +package com.loopers.application.user; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; public interface AuthenticationUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java similarity index 62% rename from apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java index b4fbf5ee6..a2c8a4b07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java @@ -1,6 +1,6 @@ -package com.loopers.application; +package com.loopers.application.user; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; public interface PasswordUpdateUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java new file mode 100644 index 000000000..46d645572 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java @@ -0,0 +1,13 @@ +package com.loopers.application.user; + +import java.time.LocalDate; + +public interface RegisterUseCase { + + void register(RegisterCommand command); + + record RegisterCommand( + String loginId, String name, String rawPassword, + LocalDate birthday, String email + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java index 75eb713e9..7531886b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java @@ -1,6 +1,6 @@ -package com.loopers.application; +package com.loopers.application.user; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; import java.time.LocalDate; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java similarity index 51% rename from apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 46a014918..c12bf11f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -1,16 +1,15 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.application.RegisterUseCase; -import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.*; +import com.loopers.application.user.PasswordUpdateUseCase; +import com.loopers.application.user.RegisterUseCase; +import com.loopers.application.user.UserQueryUseCase; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; import java.time.LocalDateTime; @Service @@ -27,18 +26,18 @@ public UserService(UserRepository userRepository, PasswordEncoder passwordEncode @Override @Transactional - public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { - UserId userId = UserId.of(loginId); - UserName userName = UserName.of(name); - Birthday birth = Birthday.of(birthday); - Email userEmail = Email.of(email); - Password password = Password.of(rawPassword, birthday); + public void register(RegisterCommand command) { + UserId userId = UserId.of(command.loginId()); + UserName userName = UserName.of(command.name()); + Birthday birth = Birthday.of(command.birthday()); + Email userEmail = Email.of(command.email()); + Password password = Password.of(command.rawPassword(), command.birthday()); String encodedPassword = passwordEncoder.encrypt(password.getValue()); try { User user = User.register( userId, userName, encodedPassword, birth, - userEmail, WrongPasswordCount.init(), LocalDateTime.now() + userEmail, LocalDateTime.now() ); userRepository.save(user); } catch (DataIntegrityViolationException ex) { @@ -50,21 +49,7 @@ public void register(String loginId, String name, String rawPassword, LocalDate @Transactional public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { User user = findUser(userId); - - LocalDate birthday = user.getBirth().getValue(); - Password currentPassword = Password.of(currentRawPassword, birthday); - Password newPassword = Password.of(newRawPassword, birthday); - - if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); - } - - if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); - } - - String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); - User updatedUser = user.changePassword(encodedNewPassword); + User updatedUser = user.changePassword(currentRawPassword, newRawPassword, passwordEncoder); userRepository.save(updatedUser); } @@ -74,7 +59,7 @@ public UserInfoResponse getUserInfo(UserId userId) { return new UserInfoResponse( user.getUserId().getValue(), - maskName(user.getUserName().getValue()), + user.getUserName().maskedValue(), user.getBirth().getValue(), user.getEmail().getValue() ); @@ -84,14 +69,4 @@ private User findUser(UserId userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); } - - private String maskName(String name) { - if (name == null || name.isEmpty()) { - return name; - } - if (name.length() == 1) { - return "*"; - } - return name.substring(0, name.length() - 1) + "*"; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java deleted file mode 100644 index f321978e2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.loopers.domain.model; - - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class User { - private Long id; - private final UserId userId; - private final UserName userName; - private final String encodedPassword; - private final Birthday birth; // YYYYMMDD format with default value - private final Email email; - private final WrongPasswordCount wrongPasswordCount; - private LocalDateTime createdAt; - - public static User register(UserId userId,UserName userName, String encodedPassword, Birthday birth, Email email, WrongPasswordCount wrongPasswordCount, LocalDateTime createdAt) { - return new User(null,userId,userName,encodedPassword,birth,email, wrongPasswordCount,createdAt); - } - - public static User reconstitute(Long id, UserId userId, UserName userName, String encodedPassword, Birthday birth, Email email, WrongPasswordCount wrongPasswordCount, LocalDateTime createdAt) { - return new User(id, userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt); - } - - public boolean matchesPassword(Password password, PasswordMatchChecker checker) { - return checker.matches(password, this.encodedPassword); - } - - public User changePassword(String newEncodedPassword) { - return new User( - this.id, - this.userId, - this.userName, - newEncodedPassword, - this.birth, - this.email, - this.wrongPasswordCount, - this.createdAt - ); - } - - @FunctionalInterface - public interface PasswordMatchChecker { - boolean matches(Password password, String encodingPassword); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java deleted file mode 100644 index 9c2e182fb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.domain.model; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Value; - -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class WrongPasswordCount { - - private final int value; - - public static WrongPasswordCount init() { - return new WrongPasswordCount(0); - } - - public static WrongPasswordCount of(int value) { - if (value < 0) { - throw new IllegalArgumentException("비밀번호 오류 횟수는 음수일 수 없습니다."); - } - return new WrongPasswordCount(value); - } - - public int getValue() { - return value; - } - - public WrongPasswordCount increment() { - return new WrongPasswordCount(this.value + 1); - } - - public WrongPasswordCount reset() { - return new WrongPasswordCount(0); - } - - public boolean isLocked() { - return this.value >= 5; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java new file mode 100644 index 000000000..8f20337ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java @@ -0,0 +1,70 @@ +package com.loopers.domain.model.brand; + +import com.loopers.domain.model.brand.event.BrandDeletedEvent; +import com.loopers.domain.model.common.AggregateRoot; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class Brand extends AggregateRoot { + + private static final int DESCRIPTION_MAX_LENGTH = 500; + + private final Long id; + private final BrandName name; + private final String description; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final LocalDateTime deletedAt; + + private Brand(Long id, BrandName name, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + public static Brand create(BrandName name, String description) { + LocalDateTime now = LocalDateTime.now(); + return new Brand(null, name, validateDescription(description), now, now, null); + } + + public static Brand reconstitute(BrandData data) { + return new Brand(data.id(), data.name(), data.description(), + data.createdAt(), data.updatedAt(), data.deletedAt()); + } + + public Brand update(BrandName name, String description) { + return new Brand(this.id, name, validateDescription(description), + this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + public Brand delete() { + if (isDeleted()) { + throw new IllegalStateException("이미 삭제된 브랜드입니다."); + } + Brand deleted = new Brand(this.id, this.name, this.description, + this.createdAt, this.updatedAt, LocalDateTime.now()); + deleted.registerEvent(new BrandDeletedEvent(this.id)); + return deleted; + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + private static String validateDescription(String description) { + if (description == null || description.isBlank()) { + return null; + } + String trimmed = description.trim(); + if (trimmed.length() > DESCRIPTION_MAX_LENGTH) { + throw new IllegalArgumentException("설명은 " + DESCRIPTION_MAX_LENGTH + "자 이하여야 합니다."); + } + return trimmed; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java new file mode 100644 index 000000000..58ec404d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java @@ -0,0 +1,13 @@ +package com.loopers.domain.model.brand; + +import java.time.LocalDateTime; + +public record BrandData( + Long id, + BrandName name, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java new file mode 100644 index 000000000..bb2ca8d71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java @@ -0,0 +1,29 @@ +package com.loopers.domain.model.brand; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class BrandName { + + private static final int MIN_LENGTH = 1; + private static final int MAX_LENGTH = 50; + + private final String value; + + private BrandName(String value) { + this.value = value; + } + + public static BrandName of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("브랜드 이름은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() < MIN_LENGTH || trimmed.length() > MAX_LENGTH) { + throw new IllegalArgumentException("브랜드 이름은 1~50자여야 합니다."); + } + return new BrandName(trimmed); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java new file mode 100644 index 000000000..8642c0650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.brand.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record BrandDeletedEvent( + Long brandId, + LocalDateTime occurredAt +) implements DomainEvent { + + public BrandDeletedEvent(Long brandId) { + this(brandId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java new file mode 100644 index 000000000..010fd01fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.common; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public abstract class AggregateRoot { + + private final List domainEvents = new ArrayList<>(); + + protected void registerEvent(DomainEvent event) { + this.domainEvents.add(event); + } + + public List getDomainEvents() { + return Collections.unmodifiableList(domainEvents); + } + + public void clearDomainEvents() { + this.domainEvents.clear(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java new file mode 100644 index 000000000..c8a9efec6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java @@ -0,0 +1,8 @@ +package com.loopers.domain.model.common; + +import java.time.LocalDateTime; + +public interface DomainEvent { + + LocalDateTime occurredAt(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java new file mode 100644 index 000000000..b4c71620b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java @@ -0,0 +1,6 @@ +package com.loopers.domain.model.common; + +public interface DomainEventPublisher { + + void publishEvents(AggregateRoot aggregateRoot); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java new file mode 100644 index 000000000..8fb3c7833 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java @@ -0,0 +1,14 @@ +package com.loopers.domain.model.common; + +import java.util.List; +import java.util.function.Function; + +public record PageResult( + List content, int page, int size, + long totalElements, int totalPages +) { + public PageResult map(Function mapper) { + List mapped = content.stream().map(mapper).toList(); + return new PageResult<>(mapped, page, size, totalElements, totalPages); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java new file mode 100644 index 000000000..ee94dfb62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java @@ -0,0 +1,47 @@ +package com.loopers.domain.model.like; + +import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.like.event.ProductLikedEvent; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; +import com.loopers.domain.model.user.UserId; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class Like extends AggregateRoot { + + private final Long id; + private final UserId userId; + private final Long productId; + private final LocalDateTime createdAt; + + private Like(Long id, UserId userId, Long productId, LocalDateTime createdAt) { + this.id = id; + this.userId = userId; + this.productId = productId; + this.createdAt = createdAt; + } + + public static Like create(UserId userId, Long productId) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + Like like = new Like(null, userId, productId, LocalDateTime.now()); + like.registerEvent(new ProductLikedEvent(productId)); + return like; + } + + public Like markUnliked() { + Like unliked = new Like(this.id, this.userId, this.productId, this.createdAt); + unliked.registerEvent(new ProductUnlikedEvent(this.productId)); + return unliked; + } + + public static Like reconstitute(Long id, UserId userId, Long productId, LocalDateTime createdAt) { + return new Like(id, userId, productId, createdAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java new file mode 100644 index 000000000..0b4f72443 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.like.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record ProductLikedEvent( + Long productId, + LocalDateTime occurredAt +) implements DomainEvent { + + public ProductLikedEvent(Long productId) { + this(productId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java new file mode 100644 index 000000000..a02f89f4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.like.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record ProductUnlikedEvent( + Long productId, + LocalDateTime occurredAt +) implements DomainEvent { + + public ProductUnlikedEvent(Long productId) { + this(productId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java new file mode 100644 index 000000000..506866b22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java @@ -0,0 +1,37 @@ +package com.loopers.domain.model.order; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class DeliveryInfo { + + private final String receiverName; + private final String address; + private final String deliveryRequest; + private final LocalDate desiredDeliveryDate; + + private DeliveryInfo(String receiverName, String address, + String deliveryRequest, LocalDate desiredDeliveryDate) { + if (receiverName == null || receiverName.isBlank()) { + throw new IllegalArgumentException("수령인 이름은 필수입니다."); + } + if (address == null || address.isBlank()) { + throw new IllegalArgumentException("배송 주소는 필수입니다."); + } + this.receiverName = receiverName.trim(); + this.address = address.trim(); + this.deliveryRequest = deliveryRequest; + this.desiredDeliveryDate = desiredDeliveryDate; + } + + public static DeliveryInfo of(String receiverName, String address, + String deliveryRequest, LocalDate desiredDeliveryDate) { + return new DeliveryInfo(receiverName, address, deliveryRequest, desiredDeliveryDate); + } + + public DeliveryInfo withAddress(String newAddress) { + return new DeliveryInfo(this.receiverName, newAddress, this.deliveryRequest, this.desiredDeliveryDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java new file mode 100644 index 000000000..718451fe6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java @@ -0,0 +1,45 @@ +package com.loopers.domain.model.order; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Money { + + private final int value; + + private Money(int value) { + this.value = value; + } + + public static Money of(int value) { + if (value < 0) { + throw new IllegalArgumentException("금액은 0 이상이어야 합니다."); + } + return new Money(value); + } + + public static Money zero() { + return new Money(0); + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money subtract(Money other) { + int result = this.value - other.value; + if (result < 0) { + throw new IllegalStateException("차감 결과 금액이 음수가 될 수 없습니다."); + } + return new Money(result); + } + + public Money multiply(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("곱할 수량은 0 이상이어야 합니다."); + } + return new Money(this.value * quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java new file mode 100644 index 000000000..65cc76666 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java @@ -0,0 +1,122 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.order.event.OrderCancelledEvent; +import com.loopers.domain.model.user.UserId; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +public class Order extends AggregateRoot { + + private final Long id; + private final UserId userId; + private final List items; + private final OrderSnapshot snapshot; + private final DeliveryInfo deliveryInfo; + private final OrderAmount orderAmount; + private final OrderStatus status; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + private Order(Long id, UserId userId, List items, OrderSnapshot snapshot, + DeliveryInfo deliveryInfo, OrderAmount orderAmount, + OrderStatus status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.items = items; + this.snapshot = snapshot; + this.deliveryInfo = deliveryInfo; + this.orderAmount = orderAmount; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static Order create(UserId userId, List orderLines, + DeliveryInfo deliveryInfo, PaymentMethod paymentMethod, + Money discountAmount) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + if (orderLines == null || orderLines.isEmpty()) { + throw new IllegalArgumentException("주문 항목은 1개 이상이어야 합니다."); + } + + List items = orderLines.stream() + .map(line -> OrderItem.create(line.productId(), line.quantity(), line.unitPrice())) + .toList(); + + String snapshotData = orderLines.stream() + .map(line -> line.productName() + ":" + line.unitPrice().getValue()) + .collect(Collectors.joining(",")); + OrderSnapshot snapshot = OrderSnapshot.create(snapshotData + ","); + + Money totalAmount = calculateTotalAmount(items); + OrderAmount orderAmount = OrderAmount.of(paymentMethod, totalAmount, discountAmount); + LocalDateTime now = LocalDateTime.now(); + + return new Order(null, userId, items, snapshot, deliveryInfo, orderAmount, + OrderStatus.PAYMENT_COMPLETED, now, now); + } + + public static Order reconstitute(OrderData data) { + return new Order(data.id(), data.userId(), data.items(), data.snapshot(), + data.deliveryInfo(), data.orderAmount(), data.status(), + data.createdAt(), data.updatedAt()); + } + + public Order cancel() { + if (!isCancellable()) { + throw new IllegalStateException("현재 상태에서는 주문을 취소할 수 없습니다. 현재 상태: " + status.getDescription()); + } + + Order cancelled = withStatus(OrderStatus.CANCELLED); + + List cancelledItems = this.items.stream() + .map(item -> new OrderCancelledEvent.CancelledItem(item.getProductId(), item.getQuantity())) + .toList(); + cancelled.registerEvent(new OrderCancelledEvent(this.id, cancelledItems)); + + return cancelled; + } + + public Order updateDeliveryAddress(String newAddress) { + if (!status.isAddressChangeable()) { + throw new IllegalStateException("현재 상태에서는 배송지를 변경할 수 없습니다. 현재 상태: " + status.getDescription()); + } + return new Order(this.id, this.userId, this.items, this.snapshot, + this.deliveryInfo.withAddress(newAddress), this.orderAmount, + this.status, this.createdAt, LocalDateTime.now()); + } + + public boolean isCancellable() { + return status.isCancellable(); + } + + private Order withStatus(OrderStatus newStatus) { + return new Order(this.id, this.userId, this.items, this.snapshot, + this.deliveryInfo, this.orderAmount, + newStatus, this.createdAt, LocalDateTime.now()); + } + + private static Money calculateTotalAmount(List items) { + return items.stream() + .map(OrderItem::calculateAmount) + .reduce(Money.zero(), Money::add); + } + + // Delegate getters + public String getReceiverName() { return deliveryInfo.getReceiverName(); } + public String getAddress() { return deliveryInfo.getAddress(); } + public String getDeliveryRequest() { return deliveryInfo.getDeliveryRequest(); } + public LocalDate getDesiredDeliveryDate() { return deliveryInfo.getDesiredDeliveryDate(); } + public PaymentMethod getPaymentMethod() { return orderAmount.getPaymentMethod(); } + public Money getTotalAmount() { return orderAmount.getTotalAmount(); } + public Money getDiscountAmount() { return orderAmount.getDiscountAmount(); } + public Money getPaymentAmount() { return orderAmount.getPaymentAmount(); } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java new file mode 100644 index 000000000..d964c0d96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java @@ -0,0 +1,41 @@ +package com.loopers.domain.model.order; + +import lombok.Getter; + +@Getter +public class OrderAmount { + + private final PaymentMethod paymentMethod; + private final Money totalAmount; + private final Money discountAmount; + private final Money paymentAmount; + + private OrderAmount(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount, Money paymentAmount) { + if (paymentMethod == null) { + throw new IllegalArgumentException("결제 수단은 필수입니다."); + } + if (totalAmount == null) { + throw new IllegalArgumentException("총 금액은 필수입니다."); + } + this.paymentMethod = paymentMethod; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount != null ? discountAmount : Money.zero(); + this.paymentAmount = paymentAmount; + } + + public static OrderAmount of(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount) { + if (totalAmount == null) { + throw new IllegalArgumentException("총 금액은 필수입니다."); + } + Money discount = discountAmount != null ? discountAmount : Money.zero(); + Money payment = totalAmount.subtract(discount); + return new OrderAmount(paymentMethod, totalAmount, discount, payment); + } + + public static OrderAmount reconstitute(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount, Money paymentAmount) { + return new OrderAmount(paymentMethod, totalAmount, discountAmount, paymentAmount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java new file mode 100644 index 000000000..df7f37294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java @@ -0,0 +1,19 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderData( + Long id, + UserId userId, + List items, + OrderSnapshot snapshot, + DeliveryInfo deliveryInfo, + OrderAmount orderAmount, + OrderStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java new file mode 100644 index 000000000..33188c619 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.order; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OrderItem { + + private final Long id; + private final Long productId; + private final int quantity; + private final Money unitPrice; + + public static OrderItem create(Long productId, int quantity, Money unitPrice) { + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + if (quantity < 1) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + } + if (unitPrice == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + return new OrderItem(null, productId, quantity, unitPrice); + } + + public static OrderItem reconstitute(Long id, Long productId, int quantity, Money unitPrice) { + return new OrderItem(id, productId, quantity, unitPrice); + } + + public Money calculateAmount() { + return unitPrice.multiply(quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java new file mode 100644 index 000000000..806715589 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java @@ -0,0 +1,23 @@ +package com.loopers.domain.model.order; + +public record OrderLine( + Long productId, + String productName, + Money unitPrice, + int quantity +) { + public OrderLine { + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new IllegalArgumentException("상품명은 필수입니다."); + } + if (unitPrice == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + if (quantity < 1) { + throw new IllegalArgumentException("수량은 1 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java new file mode 100644 index 000000000..8ad2843fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java @@ -0,0 +1,27 @@ +package com.loopers.domain.model.order; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OrderSnapshot { + + private final Long id; + private final String snapshotData; + private final LocalDateTime createdAt; + + public static OrderSnapshot create(String snapshotData) { + if (snapshotData == null || snapshotData.isBlank()) { + throw new IllegalArgumentException("스냅샷 데이터는 필수입니다."); + } + return new OrderSnapshot(null, snapshotData, LocalDateTime.now()); + } + + public static OrderSnapshot reconstitute(Long id, String snapshotData, LocalDateTime createdAt) { + return new OrderSnapshot(id, snapshotData, createdAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java new file mode 100644 index 000000000..352d71de7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java @@ -0,0 +1,28 @@ +package com.loopers.domain.model.order; + +public enum OrderStatus { + + PAYMENT_COMPLETED("결제완료"), + PREPARING("상품준비중"), + SHIPPING("배송중"), + DELIVERED("배송완료"), + CANCELLED("주문취소"); + + private final String description; + + OrderStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public boolean isCancellable() { + return this == PAYMENT_COMPLETED || this == PREPARING; + } + + public boolean isAddressChangeable() { + return this == PAYMENT_COMPLETED || this == PREPARING; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java new file mode 100644 index 000000000..9cd84223e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java @@ -0,0 +1,17 @@ +package com.loopers.domain.model.order; + +public enum PaymentMethod { + + CARD("카드"), + BANK_TRANSFER("계좌이체"); + + private final String description; + + PaymentMethod(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java new file mode 100644 index 000000000..409e832b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java @@ -0,0 +1,19 @@ +package com.loopers.domain.model.order.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderCancelledEvent( + Long orderId, + List cancelledItems, + LocalDateTime occurredAt +) implements DomainEvent { + + public record CancelledItem(Long productId, int quantity) {} + + public OrderCancelledEvent(Long orderId, List cancelledItems) { + this(orderId, cancelledItems, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java new file mode 100644 index 000000000..2ba2237d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Price { + + private final int value; + + private Price(int value) { + this.value = value; + } + + public static Price of(int value) { + if (value < 0) { + throw new IllegalArgumentException("상품 가격은 0 이상이어야 합니다."); + } + return new Price(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java new file mode 100644 index 000000000..b07c31cee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java @@ -0,0 +1,117 @@ +package com.loopers.domain.model.product; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Product { + + private static final int DESCRIPTION_MAX_LENGTH = 500; + + private final Long id; + private final Long brandId; + private final ProductName name; + private final ProductPricing pricing; + private final Stock stock; + private final int likeCount; + private final String description; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final LocalDateTime deletedAt; + + public static Product create(Long brandId, ProductName name, Price price, Price salePrice, + Stock stock, String description) { + LocalDateTime now = LocalDateTime.now(); + return new Product(null, brandId, name, ProductPricing.of(price, salePrice), stock, 0, + validateDescription(description), now, now, null); + } + + public static Product reconstitute(ProductData data) { + return new Product(data.id(), data.brandId(), data.name(), + ProductPricing.of(data.price(), data.salePrice()), data.stock(), + data.likeCount(), data.description(), + data.createdAt(), data.updatedAt(), data.deletedAt()); + } + + public Product update(ProductName name, Price price, Price salePrice, Stock stock, String description) { + return new Product(this.id, this.brandId, name, ProductPricing.of(price, salePrice), stock, this.likeCount, + validateDescription(description), this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + public Product delete() { + if (isDeleted()) { + throw new IllegalStateException("이미 삭제된 상품입니다."); + } + return withDeletedAt(LocalDateTime.now()); + } + + public Product decreaseStock(int quantity) { + return withStock(this.stock.decrease(quantity)); + } + + public Product increaseStock(int quantity) { + return withStock(this.stock.increase(quantity)); + } + + public Product increaseLikeCount() { + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount + 1, this.description, this.createdAt, this.updatedAt, this.deletedAt); + } + + public Product decreaseLikeCount() { + if (this.likeCount <= 0) { + throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); + } + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount - 1, this.description, this.createdAt, this.updatedAt, this.deletedAt); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + public Price getPrice() { + return this.pricing.getPrice(); + } + + public Price getSalePrice() { + return this.pricing.getSalePrice(); + } + + public boolean isOnSale() { + return this.pricing.isOnSale(); + } + + public int getDiscountRate() { + return this.pricing.getDiscountRate(); + } + + public boolean isSoldOut() { + return this.stock.getValue() == 0; + } + + private Product withStock(Stock newStock) { + return new Product(this.id, this.brandId, this.name, this.pricing, newStock, + this.likeCount, this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + private Product withDeletedAt(LocalDateTime newDeletedAt) { + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount, this.description, this.createdAt, this.updatedAt, newDeletedAt); + } + + private static String validateDescription(String description) { + if (description == null || description.isBlank()) { + return null; + } + String trimmed = description.trim(); + if (trimmed.length() > DESCRIPTION_MAX_LENGTH) { + throw new IllegalArgumentException("설명은 " + DESCRIPTION_MAX_LENGTH + "자 이하여야 합니다."); + } + return trimmed; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java new file mode 100644 index 000000000..e80d75203 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java @@ -0,0 +1,18 @@ +package com.loopers.domain.model.product; + +import java.time.LocalDateTime; + +public record ProductData( + Long id, + Long brandId, + ProductName name, + Price price, + Price salePrice, + Stock stock, + int likeCount, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java new file mode 100644 index 000000000..3848e6493 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java @@ -0,0 +1,28 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class ProductName { + + private static final int MAX_LENGTH = 100; + + private final String value; + + private ProductName(String value) { + this.value = value; + } + + public static ProductName of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("상품 이름은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() > MAX_LENGTH) { + throw new IllegalArgumentException("상품 이름은 100자 이하여야 합니다."); + } + return new ProductName(trimmed); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java new file mode 100644 index 000000000..a7f713a02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.product; + +import lombok.Getter; + +@Getter +public class ProductPricing { + + private final Price price; + private final Price salePrice; + + private ProductPricing(Price price, Price salePrice) { + if (price == null) { + throw new IllegalArgumentException("상품 가격은 필수입니다."); + } + this.price = price; + this.salePrice = salePrice; + } + + public static ProductPricing of(Price price, Price salePrice) { + return new ProductPricing(price, salePrice); + } + + public boolean isOnSale() { + return this.salePrice != null; + } + + public int getDiscountRate() { + if (!isOnSale()) return 0; + return calculateDiscountRate(price.getValue(), salePrice.getValue()); + } + + public static int calculateDiscountRate(int price, Integer salePrice) { + if (salePrice == null) return 0; + return (price - salePrice) * 100 / price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java new file mode 100644 index 000000000..cbd2c7862 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java @@ -0,0 +1,43 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Stock { + + private final int value; + + private Stock(int value) { + this.value = value; + } + + public static Stock of(int value) { + if (value < 0) { + throw new IllegalArgumentException("재고 수량은 0 이상이어야 합니다."); + } + return new Stock(value); + } + + public Stock decrease(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("차감 수량은 1 이상이어야 합니다."); + } + if (!hasEnough(quantity)) { + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + this.value + ", 요청 수량: " + quantity); + } + return new Stock(this.value - quantity); + } + + public Stock increase(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("증가 수량은 1 이상이어야 합니다."); + } + return new Stock(this.value + quantity); + } + + public boolean hasEnough(int quantity) { + return this.value >= quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java index 5ff4694b6..dcd592d4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java similarity index 77% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java index 9b615d564..e8200b365 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java @@ -1,14 +1,16 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class Email { private static final Pattern PATTERN = Pattern.compile( - "^[a-zA-Z0-9]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" ); private final String value; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java index f39902cdc..3096f8d1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.Data; import lombok.EqualsAndHashCode; @@ -12,7 +12,7 @@ @Getter @EqualsAndHashCode -@ToString +@ToString(exclude = "value") public class Password { private static final Pattern ALLOWED_CHARS = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]{8,16}$"); private static final DateTimeFormatter FMT_YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java new file mode 100644 index 000000000..e6304d656 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java @@ -0,0 +1,63 @@ +package com.loopers.domain.model.user; + + +import com.loopers.domain.service.PasswordEncoder; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class User { + private Long id; + private final UserId userId; + private final UserName userName; + private final String encodedPassword; + private final Birthday birth; // YYYYMMDD format with default value + private final Email email; + private final int wrongPasswordCount; + private final LocalDateTime createdAt; + + public static User register(UserId userId, UserName userName, String encodedPassword, + Birthday birth, Email email, LocalDateTime createdAt) { + return new User(null, userId, userName, encodedPassword, birth, email, 0, createdAt); + } + + public static User reconstitute(UserData data) { + return new User(data.id(), data.userId(), data.userName(), data.encodedPassword(), + data.birth(), data.email(), data.wrongPasswordCount(), data.createdAt()); + } + + public boolean matchesPassword(Password password, PasswordMatchChecker checker) { + return checker.matches(password, this.encodedPassword); + } + + public User changePassword(String currentRawPassword, String newRawPassword, + PasswordEncoder encoder) { + if (!encoder.matches(currentRawPassword, this.encodedPassword)) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + Password newPassword = Password.of(newRawPassword, this.birth.getValue()); + if (encoder.matches(newPassword.getValue(), this.encodedPassword)) { + throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); + } + String encodedNewPassword = encoder.encrypt(newPassword.getValue()); + return new User( + this.id, + this.userId, + this.userName, + encodedNewPassword, + this.birth, + this.email, + this.wrongPasswordCount, + this.createdAt + ); + } + + @FunctionalInterface + public interface PasswordMatchChecker { + boolean matches(Password password, String encodingPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java new file mode 100644 index 000000000..509ce187f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.user; + +import java.time.LocalDateTime; + +public record UserData( + Long id, + UserId userId, + UserName userName, + String encodedPassword, + Birthday birth, + Email email, + int wrongPasswordCount, + LocalDateTime createdAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java index 541f29d95..4aba21dcc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java @@ -1,10 +1,12 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class UserId { private static final Pattern PATTERN = Pattern.compile("^[a-z0-9]{4,10}$"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java similarity index 61% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java index cd5e10ff1..57de291a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java @@ -1,16 +1,18 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class UserName { private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9가-힣]{2,20}$"); private final String value; - public UserName(String value) {this.value = value;} + private UserName(String value) {this.value = value;} public static UserName of(String value) { if(value == null || value.isEmpty()) { @@ -22,4 +24,10 @@ public static UserName of(String value) { } return new UserName(trimmed); } + + public String maskedValue() { + if (value == null || value.isEmpty()) return value; + if (value.length() == 1) return "*"; + return value.substring(0, value.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java new file mode 100644 index 000000000..af78a4129 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + Optional findActiveById(Long id); + + List findAllActive(); + + List findAllByIds(List ids); + + boolean existsByName(BrandName name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java new file mode 100644 index 000000000..3430e99fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.user.UserId; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + Optional findByUserIdAndProductId(UserId userId, Long productId); + + void deleteByUserIdAndProductId(UserId userId, Long productId); + + boolean existsByUserIdAndProductId(UserId userId, Long productId); + + List findAllByUserId(UserId userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java new file mode 100644 index 000000000..d50f11b66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.order.Order; +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findAllByUserId(UserId userId); + + List findAllByUserIdAndDateRange(UserId userId, LocalDateTime startAt, LocalDateTime endAt); + + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java new file mode 100644 index 000000000..a157365c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.product.Product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + Optional findActiveById(Long id); + + Optional findActiveByIdWithLock(Long id); + + PageResult findAllActive(Long brandId, String sort, int page, int size); + + List findAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java index f2b4eae02..90e580d89 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java @@ -1,7 +1,7 @@ package com.loopers.domain.repository; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.User; +import com.loopers.domain.model.user.UserId; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java new file mode 100644 index 000000000..3b6d7c6ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.brand; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "brands") +public class BrandJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + protected BrandJpaEntity() {} + + public BrandJpaEntity(Long id, String name, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..623f76575 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.infrastructure.brand.BrandJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName(String name); + + List findAllByDeletedAtIsNull(); + + List findAllByIdIn(List ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..bf0daff58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + public BrandRepositoryImpl(BrandJpaRepository brandJpaRepository) { + this.brandJpaRepository = brandJpaRepository; + } + + @Override + public Brand save(Brand brand) { + BrandJpaEntity entity = toEntity(brand); + BrandJpaEntity saved = brandJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .map(this::toDomain); + } + + @Override + public Optional findActiveById(Long id) { + return findById(id).filter(b -> !b.isDeleted()); + } + + @Override + public List findAllActive() { + return brandJpaRepository.findAllByDeletedAtIsNull().stream() + .map(this::toDomain) + .toList(); + } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIdIn(ids).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public boolean existsByName(BrandName name) { + return brandJpaRepository.existsByName(name.getValue()); + } + + private BrandJpaEntity toEntity(Brand brand) { + return new BrandJpaEntity( + brand.getId(), + brand.getName().getValue(), + brand.getDescription(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } + + private Brand toDomain(BrandJpaEntity entity) { + return Brand.reconstitute(new BrandData( + entity.getId(), + BrandName.of(entity.getName()), + entity.getDescription(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java new file mode 100644 index 000000000..aaf9f8353 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.common; + +import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.common.DomainEventPublisher; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class SpringDomainEventPublisher implements DomainEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + public SpringDomainEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @Override + public void publishEvents(AggregateRoot aggregateRoot) { + aggregateRoot.getDomainEvents().forEach(eventPublisher::publishEvent); + aggregateRoot.clearDomainEvents(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java new file mode 100644 index 000000000..bf999609c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.like; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(columnNames = {"userId", "productId"}) +}) +public class LikeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected LikeJpaEntity() {} + + public LikeJpaEntity(Long id, String userId, Long productId, LocalDateTime createdAt) { + this.id = id; + this.userId = userId; + this.productId = productId; + this.createdAt = createdAt; + } +} 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..f33202abc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.like; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(String userId, Long productId); + + boolean existsByUserIdAndProductId(String userId, Long productId); + + void deleteByUserIdAndProductId(String userId, Long productId); + + List findAllByUserId(String userId); + + @Query("SELECT l, p, b FROM LikeJpaEntity l " + + "JOIN ProductJpaEntity p ON l.productId = p.id " + + "JOIN BrandJpaEntity b ON p.brandId = b.id " + + "WHERE l.userId = :userId AND p.deletedAt IS NULL") + List findAllWithProductByUserId(@Param("userId") String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..b4c29b8b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,92 @@ +package com.loopers.infrastructure.like; + +import com.loopers.application.like.LikeProductReadPort; +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.infrastructure.brand.BrandJpaEntity; +import com.loopers.infrastructure.product.ProductJpaEntity; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class LikeRepositoryImpl implements LikeRepository, LikeProductReadPort { + + private final LikeJpaRepository likeJpaRepository; + + public LikeRepositoryImpl(LikeJpaRepository likeJpaRepository) { + this.likeJpaRepository = likeJpaRepository; + } + + @Override + public Like save(Like like) { + LikeJpaEntity entity = toEntity(like); + LikeJpaEntity saved = likeJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findByUserIdAndProductId(UserId userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId.getValue(), productId) + .map(this::toDomain); + } + + @Override + public void deleteByUserIdAndProductId(UserId userId, Long productId) { + likeJpaRepository.deleteByUserIdAndProductId(userId.getValue(), productId); + } + + @Override + public boolean existsByUserIdAndProductId(UserId userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId.getValue(), productId); + } + + @Override + public List findAllByUserId(UserId userId) { + return likeJpaRepository.findAllByUserId(userId.getValue()).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public List findLikedProductsByUserId(UserId userId) { + return likeJpaRepository.findAllWithProductByUserId(userId.getValue()).stream() + .map(this::toLikeProductView) + .toList(); + } + + private LikeProductView toLikeProductView(Object[] row) { + LikeJpaEntity like = (LikeJpaEntity) row[0]; + ProductJpaEntity product = (ProductJpaEntity) row[1]; + BrandJpaEntity brand = (BrandJpaEntity) row[2]; + return new LikeProductView( + product.getId(), + product.getName(), + product.getPrice(), + product.getSalePrice(), + product.getStockQuantity(), + brand.getName(), + like.getCreatedAt() + ); + } + + private LikeJpaEntity toEntity(Like like) { + return new LikeJpaEntity( + like.getId(), + like.getUserId().getValue(), + like.getProductId(), + like.getCreatedAt() + ); + } + + private Like toDomain(LikeJpaEntity entity) { + return Like.reconstitute( + entity.getId(), + UserId.of(entity.getUserId()), + entity.getProductId(), + entity.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java new file mode 100644 index 000000000..d616aae31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.order; + +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +@Table(name = "order_items") +public class OrderItemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private int unitPrice; + + protected OrderItemJpaEntity() {} + + public OrderItemJpaEntity(Long id, Long productId, int quantity, int unitPrice) { + this.id = id; + this.productId = productId; + this.quantity = quantity; + this.unitPrice = unitPrice; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java new file mode 100644 index 000000000..7ade164a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.order; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "orders") +public class OrderJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "order_id", nullable = false) + private List items = new ArrayList<>(); + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "snapshot_id") + private OrderSnapshotJpaEntity snapshot; + + @Column(nullable = false) + private String receiverName; + + @Column(nullable = false) + private String address; + + private String deliveryRequest; + + @Column(nullable = false) + private String paymentMethod; + + @Column(nullable = false) + private int totalAmount; + + @Column(nullable = false) + private int discountAmount; + + @Column(nullable = false) + private int paymentAmount; + + @Column(nullable = false) + private String status; + + private LocalDate desiredDeliveryDate; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + protected OrderJpaEntity() {} + + public OrderJpaEntity(Long id, String userId, List items, + OrderSnapshotJpaEntity snapshot, String receiverName, String address, + String deliveryRequest, String paymentMethod, + int totalAmount, int discountAmount, int paymentAmount, + String status, LocalDate desiredDeliveryDate, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.items = items; + this.snapshot = snapshot; + this.receiverName = receiverName; + this.address = address; + this.deliveryRequest = deliveryRequest; + this.paymentMethod = paymentMethod; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.paymentAmount = paymentAmount; + this.status = status; + this.desiredDeliveryDate = desiredDeliveryDate; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} 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..38ca87f9b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserId(String userId); + + List findAllByUserIdAndCreatedAtBetween(String userId, LocalDateTime startAt, LocalDateTime endAt); +} 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..0707d0cf0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,150 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + public OrderRepositoryImpl(OrderJpaRepository orderJpaRepository) { + this.orderJpaRepository = orderJpaRepository; + } + + @Override + public Order save(Order order) { + OrderJpaEntity entity = toEntity(order); + OrderJpaEntity saved = orderJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id) + .map(this::toDomain); + } + + @Override + public List findAllByUserId(UserId userId) { + return orderJpaRepository.findAllByUserId(userId.getValue()).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public List findAllByUserIdAndDateRange(UserId userId, LocalDateTime startAt, LocalDateTime endAt) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId.getValue(), startAt, endAt).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public List findAll() { + return orderJpaRepository.findAll().stream() + .map(this::toDomain) + .toList(); + } + + private OrderJpaEntity toEntity(Order order) { + List itemEntities = order.getItems().stream() + .map(this::toItemEntity) + .toList(); + + OrderSnapshotJpaEntity snapshotEntity = null; + if (order.getSnapshot() != null) { + snapshotEntity = toSnapshotEntity(order.getSnapshot()); + } + + return new OrderJpaEntity( + order.getId(), + order.getUserId().getValue(), + itemEntities, + snapshotEntity, + order.getReceiverName(), + order.getAddress(), + order.getDeliveryRequest(), + order.getPaymentMethod().name(), + order.getTotalAmount().getValue(), + order.getDiscountAmount().getValue(), + order.getPaymentAmount().getValue(), + order.getStatus().name(), + order.getDesiredDeliveryDate(), + order.getCreatedAt(), + order.getUpdatedAt() + ); + } + + private OrderItemJpaEntity toItemEntity(OrderItem item) { + return new OrderItemJpaEntity( + item.getId(), + item.getProductId(), + item.getQuantity(), + item.getUnitPrice().getValue() + ); + } + + private OrderSnapshotJpaEntity toSnapshotEntity(OrderSnapshot snapshot) { + return new OrderSnapshotJpaEntity( + snapshot.getId(), + snapshot.getSnapshotData(), + snapshot.getCreatedAt() + ); + } + + private Order toDomain(OrderJpaEntity entity) { + List items = entity.getItems().stream() + .map(this::toItemDomain) + .toList(); + + OrderSnapshot snapshot = null; + if (entity.getSnapshot() != null) { + snapshot = OrderSnapshot.reconstitute( + entity.getSnapshot().getId(), + entity.getSnapshot().getSnapshotData(), + entity.getSnapshot().getCreatedAt() + ); + } + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + entity.getReceiverName(), + entity.getAddress(), + entity.getDeliveryRequest(), + entity.getDesiredDeliveryDate() + ); + + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.valueOf(entity.getPaymentMethod()), + Money.of(entity.getTotalAmount()), + Money.of(entity.getDiscountAmount()), + Money.of(entity.getPaymentAmount()) + ); + + return Order.reconstitute(new OrderData( + entity.getId(), + UserId.of(entity.getUserId()), + items, + snapshot, + deliveryInfo, + orderAmount, + OrderStatus.valueOf(entity.getStatus()), + entity.getCreatedAt(), + entity.getUpdatedAt() + )); + } + + private OrderItem toItemDomain(OrderItemJpaEntity entity) { + return OrderItem.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getQuantity(), + Money.of(entity.getUnitPrice()) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java new file mode 100644 index 000000000..082c7a2b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "order_snapshots") +public class OrderSnapshotJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String snapshotData; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected OrderSnapshotJpaEntity() {} + + public OrderSnapshotJpaEntity(Long id, String snapshotData, LocalDateTime createdAt) { + this.id = id; + this.snapshotData = snapshotData; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java new file mode 100644 index 000000000..fe3c0aef6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.product; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "products") +public class ProductJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long brandId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private int stockQuantity; + + @Column(nullable = false) + private int likeCount; + + private String description; + + private Integer salePrice; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + protected ProductJpaEntity() {} + + public ProductJpaEntity(Long id, Long brandId, String name, int price, Integer salePrice, + int stockQuantity, int likeCount, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.brandId = brandId; + this.name = name; + this.price = price; + this.salePrice = salePrice; + this.stockQuantity = stockQuantity; + this.likeCount = likeCount; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } +} 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..0bd86dbd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.product; + +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductJpaEntity p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} 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..d905fb1e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,115 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.product.*; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + public ProductRepositoryImpl(ProductJpaRepository productJpaRepository) { + this.productJpaRepository = productJpaRepository; + } + + @Override + public Product save(Product product) { + ProductJpaEntity entity = toEntity(product); + ProductJpaEntity saved = productJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id) + .map(this::toDomain); + } + + @Override + public Optional findActiveById(Long id) { + return findById(id).filter(p -> !p.isDeleted()); + } + + @Override + public Optional findActiveByIdWithLock(Long id) { + return productJpaRepository.findByIdForUpdate(id) + .map(this::toDomain) + .filter(p -> !p.isDeleted()); + } + + @Override + public PageResult findAllActive(Long brandId, String sort, int page, int size) { + Sort sorting = resolveSort(sort); + PageRequest pageRequest = PageRequest.of(page, size, sorting); + + Page jpaPage; + if (brandId != null) { + jpaPage = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageRequest); + } else { + jpaPage = productJpaRepository.findAllByDeletedAtIsNull(pageRequest); + } + + List content = jpaPage.getContent().stream().map(this::toDomain).toList(); + return new PageResult<>(content, jpaPage.getNumber(), jpaPage.getSize(), + jpaPage.getTotalElements(), jpaPage.getTotalPages()); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId).stream() + .map(this::toDomain) + .toList(); + } + + private Sort resolveSort(String sort) { + if (sort == null) { + return Sort.by(Sort.Direction.DESC, "createdAt"); + } + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "price_desc" -> Sort.by(Sort.Direction.DESC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } + + private ProductJpaEntity toEntity(Product product) { + return new ProductJpaEntity( + product.getId(), + product.getBrandId(), + product.getName().getValue(), + product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, + product.getStock().getValue(), + product.getLikeCount(), + product.getDescription(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } + + private Product toDomain(ProductJpaEntity entity) { + return Product.reconstitute(new ProductData( + entity.getId(), + entity.getBrandId(), + ProductName.of(entity.getName()), + Price.of(entity.getPrice()), + entity.getSalePrice() != null ? Price.of(entity.getSalePrice()) : null, + Stock.of(entity.getStockQuantity()), + entity.getLikeCount(), + entity.getDescription(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java similarity index 69% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java index faccc97ed..d6524c009 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java @@ -1,9 +1,9 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.user; -import com.loopers.domain.model.Birthday; -import com.loopers.domain.model.Email; -import com.loopers.domain.model.UserId; -import com.loopers.domain.model.UserName; +import com.loopers.domain.model.user.Birthday; +import com.loopers.domain.model.user.Email; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.model.user.UserName; import jakarta.persistence.*; import lombok.Getter; @@ -34,19 +34,24 @@ public class UserJpaEntity { @Column(nullable = false) private String email; + @Column(nullable = false) + private int wrongPasswordCount; + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; protected UserJpaEntity() {} - public UserJpaEntity(Long id, UserId userId, String encodedPassword, UserName userName, Birthday birth, Email email, LocalDateTime createdAt) { + public UserJpaEntity(Long id, UserId userId, String encodedPassword, UserName userName, + Birthday birth, Email email, int wrongPasswordCount, LocalDateTime createdAt) { this.id = id; this.userId = userId.getValue(); this.encodedPassword = encodedPassword; this.username = userName.getValue(); this.birthday = birth.getValue(); this.email = email.getValue(); + this.wrongPasswordCount = wrongPasswordCount; this.createdAt = createdAt; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 72% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 6a49ba3e6..b09fc5342 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.user; -import com.loopers.infrastructure.entity.UserJpaEntity; +import com.loopers.infrastructure.user.UserJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 226376350..534efeb6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,9 +1,7 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; -import com.loopers.infrastructure.entity.UserJpaEntity; -import com.loopers.infrastructure.repository.UserJpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -13,7 +11,9 @@ public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; - public UserRepositoryImpl( UserJpaRepository userJpaRepository) {this.userJpaRepository = userJpaRepository;} + public UserRepositoryImpl(UserJpaRepository userJpaRepository) { + this.userJpaRepository = userJpaRepository; + } @Override public User save(User user) { @@ -41,21 +41,21 @@ private UserJpaEntity toEntity(User user) { user.getUserName(), user.getBirth(), user.getEmail(), + user.getWrongPasswordCount(), user.getCreatedAt() ); } private User toDomain(UserJpaEntity entity) { - return User.reconstitute( + return User.reconstitute(new UserData( entity.getId(), UserId.of(entity.getUserId()), UserName.of(entity.getUsername()), entity.getEncodedPassword(), Birthday.of(entity.getBirthday()), Email.of(entity.getEmail()), - WrongPasswordCount.init(), + entity.getWrongPasswordCount(), entity.getCreatedAt() - ); + )); } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java deleted file mode 100644 index 312fc8d70..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.application.RegisterUseCase; -import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.UserId; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserInfoResponse; -import com.loopers.interfaces.api.dto.UserRegisterRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/users") -public class UserController { - - private final RegisterUseCase registerUseCase; - private final UserQueryUseCase userQueryUseCase; - private final PasswordUpdateUseCase passwordUpdateUseCase; - - public UserController( - RegisterUseCase registerUseCase, - UserQueryUseCase userQueryUseCase, - PasswordUpdateUseCase passwordUpdateUseCase - ) { - this.registerUseCase = registerUseCase; - this.userQueryUseCase = userQueryUseCase; - this.passwordUpdateUseCase = passwordUpdateUseCase; - } - - @PostMapping("/register") - public ResponseEntity register(@Valid @RequestBody UserRegisterRequest request) { - registerUseCase.register( - request.loginId(), - request.name(), - request.password(), - request.birthday(), - request.email() - ); - return ResponseEntity.ok().build(); - } - - @GetMapping("/me") - public ResponseEntity getMyInfo(HttpServletRequest request) { - UserId userId = (UserId) request.getAttribute("authenticatedUserId"); - - var userInfo = userQueryUseCase.getUserInfo(userId); - return ResponseEntity.ok(UserInfoResponse.from(userInfo)); - } - - @PutMapping("/me/password") - public ResponseEntity updatePassword( - HttpServletRequest request, - @Valid @RequestBody PasswordUpdateRequest passwordUpdateRequest - ) { - UserId userId = (UserId) request.getAttribute("authenticatedUserId"); - - passwordUpdateUseCase.updatePassword( - userId, - passwordUpdateRequest.currentPassword(), - passwordUpdateRequest.newPassword() - ); - return ResponseEntity.ok().build(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java new file mode 100644 index 000000000..5ecdd3ee5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandQueryUseCase; +import com.loopers.application.brand.CreateBrandUseCase; +import com.loopers.application.brand.DeleteBrandUseCase; +import com.loopers.application.brand.UpdateBrandUseCase; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandResponse; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminController { + + private final CreateBrandUseCase createBrandUseCase; + private final UpdateBrandUseCase updateBrandUseCase; + private final DeleteBrandUseCase deleteBrandUseCase; + private final BrandQueryUseCase brandQueryUseCase; + + public BrandAdminController(CreateBrandUseCase createBrandUseCase, + UpdateBrandUseCase updateBrandUseCase, + DeleteBrandUseCase deleteBrandUseCase, + BrandQueryUseCase brandQueryUseCase) { + this.createBrandUseCase = createBrandUseCase; + this.updateBrandUseCase = updateBrandUseCase; + this.deleteBrandUseCase = deleteBrandUseCase; + this.brandQueryUseCase = brandQueryUseCase; + } + + @PostMapping + public ResponseEntity createBrand(@RequestBody BrandCreateRequest request) { + createBrandUseCase.createBrand(request.name(), request.description()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{brandId}") + public ResponseEntity updateBrand(@PathVariable Long brandId, + @RequestBody BrandUpdateRequest request) { + updateBrandUseCase.updateBrand(brandId, request.name(), request.description()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{brandId}") + public ResponseEntity deleteBrand(@PathVariable Long brandId) { + deleteBrandUseCase.deleteBrand(brandId); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getBrands() { + List brands = brandQueryUseCase.getBrands().stream() + .map(BrandResponse::from) + .toList(); + return ResponseEntity.ok(brands); + } + + @GetMapping("/{brandId}") + public ResponseEntity getBrand(@PathVariable Long brandId) { + BrandQueryUseCase.BrandInfo brandInfo = brandQueryUseCase.getBrand(brandId); + return ResponseEntity.ok(BrandResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..73c4cc329 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandQueryUseCase; +import com.loopers.interfaces.api.brand.dto.BrandResponse; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequestMapping("/api/v1/brands") +public class BrandController { + + private final BrandQueryUseCase brandQueryUseCase; + + public BrandController(BrandQueryUseCase brandQueryUseCase) { + this.brandQueryUseCase = brandQueryUseCase; + } + + @GetMapping("/{brandId}") + public ResponseEntity getBrand(@PathVariable Long brandId) { + BrandQueryUseCase.BrandInfo brandInfo = brandQueryUseCase.getBrand(brandId); + return ResponseEntity.ok(BrandResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java new file mode 100644 index 000000000..c21a5fdfe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.interfaces.api.brand.dto; + +public record BrandCreateRequest( + String name, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java new file mode 100644 index 000000000..d47aa68f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.brand.BrandQueryUseCase; + +public record BrandResponse( + Long id, + String name, + String description +) { + public static BrandResponse from(BrandQueryUseCase.BrandInfo brandInfo) { + return new BrandResponse( + brandInfo.id(), + brandInfo.name(), + brandInfo.description() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java new file mode 100644 index 000000000..9c26945b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.interfaces.api.brand.dto; + +public record BrandUpdateRequest( + String name, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java new file mode 100644 index 000000000..ecb3558e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.common; + +import com.loopers.domain.model.common.PageResult; + +import java.util.List; +import java.util.function.Function; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(PageResult pageResult, Function mapper) { + List content = pageResult.content().stream() + .map(mapper) + .toList(); + return new PageResponse<>(content, pageResult.page(), pageResult.size(), + pageResult.totalElements(), pageResult.totalPages()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java index 773e0928f..2893688c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.config; +import com.loopers.interfaces.api.interceptor.AdminAuthenticationInterceptor; import com.loopers.interfaces.api.interceptor.AuthenticationInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; @@ -9,14 +10,23 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthenticationInterceptor authenticationInterceptor; + private final AdminAuthenticationInterceptor adminAuthenticationInterceptor; - public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor) { + public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor, + AdminAuthenticationInterceptor adminAuthenticationInterceptor) { this.authenticationInterceptor = authenticationInterceptor; + this.adminAuthenticationInterceptor = adminAuthenticationInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor) - .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**"); + .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**") + .addPathPatterns("/api/v1/users/*/likes") + .addPathPatterns("/api/v1/products/*/likes") + .addPathPatterns("/api/v1/orders", "/api/v1/orders/**"); + + registry.addInterceptor(adminAuthenticationInterceptor) + .addPathPatterns("/api-admin/v1/**"); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java deleted file mode 100644 index 86fbbed7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -import java.time.LocalDate; - -public record UserRegisterRequest( - @NotBlank String loginId, - @NotBlank String password, - @NotBlank String name, - @NotNull LocalDate birthday, - @NotBlank @Email String email -) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java new file mode 100644 index 000000000..9f424cf5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.interceptor; + +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthenticationInterceptor implements HandlerInterceptor { + + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String ldap = request.getHeader("X-Loopers-Ldap"); + + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + sendUnauthorizedResponse(response); + return false; + } + return true; + } + + private void sendUnauthorizedResponse(HttpServletResponse response) throws Exception { + ErrorType errorType = ErrorType.UNAUTHORIZED; + response.setStatus(errorType.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + "{\"code\":\"" + errorType.getCode() + "\",\"message\":\"" + errorType.getMessage() + "\"}" + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java index 328f8e7d9..aa79f6a48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.interceptor; -import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.UserId; +import com.loopers.application.user.AuthenticationUseCase; +import com.loopers.domain.model.user.UserId; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..c6c69780a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeUseCase; +import com.loopers.application.like.UnlikeUseCase; +import com.loopers.domain.model.user.UserId; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +public class LikeController { + + private final LikeUseCase likeUseCase; + private final UnlikeUseCase unlikeUseCase; + + public LikeController(LikeUseCase likeUseCase, UnlikeUseCase unlikeUseCase) { + this.likeUseCase = likeUseCase; + this.unlikeUseCase = unlikeUseCase; + } + + @PostMapping("/{productId}/likes") + public ResponseEntity like(HttpServletRequest request, @PathVariable Long productId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + likeUseCase.like(userId, productId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}/likes") + public ResponseEntity unlike(HttpServletRequest request, @PathVariable Long productId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + unlikeUseCase.unlike(userId, productId); + return ResponseEntity.ok().build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java new file mode 100644 index 000000000..45311e2f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.like.dto; + +import com.loopers.application.like.LikeQueryUseCase; + +import java.time.LocalDateTime; + +public record LikeResponse( + Long productId, + String productName, + int price, + Integer salePrice, + boolean onSale, + int discountRate, + String brandName, + boolean soldOut, + LocalDateTime likedAt +) { + public static LikeResponse from(LikeQueryUseCase.LikeInfo info) { + return new LikeResponse( + info.productId(), + info.productName(), + info.price(), + info.salePrice(), + info.onSale(), + info.discountRate(), + info.brandName(), + info.soldOut(), + info.likedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java new file mode 100644 index 000000000..74afcf1eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderQueryUseCase; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; +import org.springframework.http.ResponseEntity; +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.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminController { + + private final OrderQueryUseCase orderQueryUseCase; + + public OrderAdminController(OrderQueryUseCase orderQueryUseCase) { + this.orderQueryUseCase = orderQueryUseCase; + } + + @GetMapping + public ResponseEntity> getAllOrders() { + List orders = orderQueryUseCase.getAllOrders().stream() + .map(OrderSummaryResponse::from) + .toList(); + return ResponseEntity.ok(orders); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrderDetail(@PathVariable Long orderId) { + OrderQueryUseCase.OrderDetail detail = orderQueryUseCase.getOrderDetail(orderId); + return ResponseEntity.ok(OrderDetailResponse.from(detail)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..5cb4c5fe9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,91 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.CancelOrderUseCase; +import com.loopers.application.order.CreateOrderUseCase; +import com.loopers.application.order.OrderQueryUseCase; +import com.loopers.application.order.UpdateDeliveryAddressUseCase; +import com.loopers.domain.model.user.UserId; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final CreateOrderUseCase createOrderUseCase; + private final OrderQueryUseCase orderQueryUseCase; + private final CancelOrderUseCase cancelOrderUseCase; + private final UpdateDeliveryAddressUseCase updateDeliveryAddressUseCase; + + public OrderController(CreateOrderUseCase createOrderUseCase, + OrderQueryUseCase orderQueryUseCase, + CancelOrderUseCase cancelOrderUseCase, + UpdateDeliveryAddressUseCase updateDeliveryAddressUseCase) { + this.createOrderUseCase = createOrderUseCase; + this.orderQueryUseCase = orderQueryUseCase; + this.cancelOrderUseCase = cancelOrderUseCase; + this.updateDeliveryAddressUseCase = updateDeliveryAddressUseCase; + } + + @PostMapping + public ResponseEntity createOrder(HttpServletRequest request, + @RequestBody OrderCreateRequest orderCreateRequest) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + createOrderUseCase.createOrder(userId, orderCreateRequest.toCommand()); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getMyOrders( + HttpServletRequest request, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + + List summaries; + if (startAt != null && endAt != null) { + summaries = orderQueryUseCase.getMyOrders(userId, startAt, endAt); + } else { + summaries = orderQueryUseCase.getMyOrders(userId); + } + + List orders = summaries.stream() + .map(OrderSummaryResponse::from) + .toList(); + return ResponseEntity.ok(orders); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(HttpServletRequest request, + @PathVariable Long orderId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + OrderQueryUseCase.OrderDetail detail = orderQueryUseCase.getOrder(userId, orderId); + return ResponseEntity.ok(OrderDetailResponse.from(detail)); + } + + @PostMapping("/{orderId}/cancel") + public ResponseEntity cancelOrder(HttpServletRequest request, + @PathVariable Long orderId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + cancelOrderUseCase.cancelOrder(userId, orderId); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{orderId}/delivery-address") + public ResponseEntity updateDeliveryAddress(HttpServletRequest request, + @PathVariable Long orderId, + @RequestBody DeliveryAddressUpdateRequest addressRequest) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + updateDeliveryAddressUseCase.updateDeliveryAddress(userId, orderId, addressRequest.address()); + return ResponseEntity.ok().build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java new file mode 100644 index 000000000..1b030252f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.order.dto; + +public record DeliveryAddressUpdateRequest( + String address +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java new file mode 100644 index 000000000..3b4396f40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.order.CreateOrderUseCase; + +import java.time.LocalDate; +import java.util.List; + +public record OrderCreateRequest( + List items, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + LocalDate desiredDeliveryDate +) { + public CreateOrderUseCase.OrderCommand toCommand() { + List itemCommands = items.stream() + .map(item -> new CreateOrderUseCase.OrderItemCommand(item.productId(), item.quantity())) + .toList(); + + return new CreateOrderUseCase.OrderCommand( + itemCommands, receiverName, address, deliveryRequest, paymentMethod, desiredDeliveryDate + ); + } + + public record OrderItemRequest( + Long productId, + int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java new file mode 100644 index 000000000..9a3ee5a8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.order.OrderQueryUseCase; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderDetailResponse( + Long id, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + int totalAmount, + int discountAmount, + int paymentAmount, + String status, + List items, + LocalDateTime createdAt +) { + public static OrderDetailResponse from(OrderQueryUseCase.OrderDetail detail) { + List itemResponses = detail.items().stream() + .map(item -> new OrderItemResponse(item.productId(), item.quantity(), item.unitPrice())) + .toList(); + + return new OrderDetailResponse( + detail.id(), + detail.receiverName(), + detail.address(), + detail.deliveryRequest(), + detail.paymentMethod(), + detail.totalAmount(), + detail.discountAmount(), + detail.paymentAmount(), + detail.status(), + itemResponses, + detail.createdAt() + ); + } + + public record OrderItemResponse( + Long productId, + int quantity, + int unitPrice + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java new file mode 100644 index 000000000..6887d5048 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.order.OrderQueryUseCase; + +import java.time.LocalDateTime; + +public record OrderSummaryResponse( + Long id, + String status, + int paymentAmount, + LocalDateTime createdAt +) { + public static OrderSummaryResponse from(OrderQueryUseCase.OrderSummary summary) { + return new OrderSummaryResponse( + summary.id(), + summary.status(), + summary.paymentAmount(), + summary.createdAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java new file mode 100644 index 000000000..4642f5806 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.CreateProductUseCase; +import com.loopers.application.product.DeleteProductUseCase; +import com.loopers.application.product.ProductQueryUseCase; +import com.loopers.application.product.UpdateProductUseCase; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; +import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; +import com.loopers.domain.model.common.PageResult; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminController { + + private final CreateProductUseCase createProductUseCase; + private final UpdateProductUseCase updateProductUseCase; + private final DeleteProductUseCase deleteProductUseCase; + private final ProductQueryUseCase productQueryUseCase; + + public ProductAdminController(CreateProductUseCase createProductUseCase, + UpdateProductUseCase updateProductUseCase, + DeleteProductUseCase deleteProductUseCase, + ProductQueryUseCase productQueryUseCase) { + this.createProductUseCase = createProductUseCase; + this.updateProductUseCase = updateProductUseCase; + this.deleteProductUseCase = deleteProductUseCase; + this.productQueryUseCase = productQueryUseCase; + } + + @GetMapping + public ResponseEntity> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult products = + productQueryUseCase.getProducts(brandId, null, page, size); + return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); + } + + @PostMapping + public ResponseEntity createProduct(@RequestBody ProductCreateRequest request) { + createProductUseCase.createProduct(request.toCommand()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{productId}") + public ResponseEntity updateProduct(@PathVariable Long productId, + @RequestBody ProductUpdateRequest request) { + updateProductUseCase.updateProduct(request.toCommand(productId)); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct(@PathVariable Long productId) { + deleteProductUseCase.deleteProduct(productId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductQueryUseCase.ProductDetailInfo info = productQueryUseCase.getProduct(productId); + return ResponseEntity.ok(ProductDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..28697636d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductQueryUseCase; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; +import com.loopers.domain.model.common.PageResult; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductQueryUseCase productQueryUseCase; + + public ProductController(ProductQueryUseCase productQueryUseCase) { + this.productQueryUseCase = productQueryUseCase; + } + + @GetMapping + public ResponseEntity> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult products = + productQueryUseCase.getProducts(brandId, sort, page, size); + return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductQueryUseCase.ProductDetailInfo info = productQueryUseCase.getProduct(productId); + return ResponseEntity.ok(ProductDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java new file mode 100644 index 000000000..52ce08927 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.CreateProductUseCase.ProductCreateCommand; + +public record ProductCreateRequest( + Long brandId, + String name, + int price, + Integer salePrice, + int stock, + String description +) { + public ProductCreateCommand toCommand() { + return new ProductCreateCommand(brandId, name, price, salePrice, stock, description); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java new file mode 100644 index 000000000..2a9d3e6ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.ProductQueryUseCase; + +public record ProductDetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + Integer salePrice, + boolean onSale, + int stock, + int likeCount, + String description +) { + public static ProductDetailResponse from(ProductQueryUseCase.ProductDetailInfo info) { + return new ProductDetailResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.salePrice(), + info.onSale(), + info.stock(), + info.likeCount(), + info.description() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java new file mode 100644 index 000000000..7bc99bc8b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.ProductQueryUseCase; + +public record ProductSummaryResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + Integer salePrice, + boolean onSale, + int likeCount +) { + public static ProductSummaryResponse from(ProductQueryUseCase.ProductSummaryInfo info) { + return new ProductSummaryResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.salePrice(), + info.onSale(), + info.likeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java new file mode 100644 index 000000000..da063c2e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.UpdateProductUseCase.ProductUpdateCommand; + +public record ProductUpdateRequest( + String name, + int price, + Integer salePrice, + int stock, + String description +) { + public ProductUpdateCommand toCommand(Long productId) { + return new ProductUpdateCommand(productId, name, price, salePrice, stock, description); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java new file mode 100644 index 000000000..fdb3ab9a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -0,0 +1,85 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.like.LikeQueryUseCase; +import com.loopers.application.user.PasswordUpdateUseCase; +import com.loopers.application.user.RegisterUseCase; +import com.loopers.application.user.UserQueryUseCase; +import com.loopers.domain.model.user.UserId; +import com.loopers.interfaces.api.like.dto.LikeResponse; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserInfoResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/users") +public class UserController { + + private final RegisterUseCase registerUseCase; + private final UserQueryUseCase userQueryUseCase; + private final PasswordUpdateUseCase passwordUpdateUseCase; + private final LikeQueryUseCase likeQueryUseCase; + + public UserController( + RegisterUseCase registerUseCase, + UserQueryUseCase userQueryUseCase, + PasswordUpdateUseCase passwordUpdateUseCase, + LikeQueryUseCase likeQueryUseCase + ) { + this.registerUseCase = registerUseCase; + this.userQueryUseCase = userQueryUseCase; + this.passwordUpdateUseCase = passwordUpdateUseCase; + this.likeQueryUseCase = likeQueryUseCase; + } + + @PostMapping + public ResponseEntity register(@RequestBody UserRegisterRequest request) { + registerUseCase.register(request.toCommand()); + return ResponseEntity.ok().build(); + } + + @GetMapping("/me") + public ResponseEntity getMyInfo(HttpServletRequest request) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + + var userInfo = userQueryUseCase.getUserInfo(userId); + return ResponseEntity.ok(UserInfoResponse.from(userInfo)); + } + + @GetMapping("/{userId}/likes") + public ResponseEntity> getMyLikes( + @PathVariable String userId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(required = false) Boolean saleYn, + @RequestParam(required = false) String status, + HttpServletRequest request) { + UserId authenticatedUserId = (UserId) request.getAttribute("authenticatedUserId"); + if (!authenticatedUserId.getValue().equals(userId)) { + return ResponseEntity.status(403).build(); + } + List likes = likeQueryUseCase.getMyLikes(authenticatedUserId, sort, saleYn, status).stream() + .map(LikeResponse::from) + .toList(); + return ResponseEntity.ok(likes); + } + + @PutMapping("/me/password") + public ResponseEntity updatePassword( + HttpServletRequest request, + @RequestBody PasswordUpdateRequest passwordUpdateRequest + ) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + + passwordUpdateUseCase.updatePassword( + userId, + passwordUpdateRequest.currentPassword(), + passwordUpdateRequest.newPassword() + ); + return ResponseEntity.ok().build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java similarity index 69% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java index 24a38ea36..3944b86bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; public record PasswordUpdateRequest( String currentPassword, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java index e06b1acb1..562726436 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.UserQueryUseCase; import java.time.format.DateTimeFormatter; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java new file mode 100644 index 000000000..f02420f48 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.user.dto; + +import com.loopers.application.user.RegisterUseCase.RegisterCommand; + +import java.time.LocalDate; + +public record UserRegisterRequest( + String loginId, + String password, + String name, + LocalDate birthday, + String email +) { + public RegisterCommand toCommand() { + return new RegisterCommand(loginId, name, password, birthday, email); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java index 105a897c7..54b1de7b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java @@ -2,18 +2,19 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; - import java.util.Map; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CoreException.class) public ResponseEntity> handleCoreException(CoreException e) { + log.error("Unhandled exception occurred", e); return ResponseEntity .status(e.getErrorType().getStatus()) .body(Map.of( @@ -32,21 +33,6 @@ public ResponseEntity> handleIllegalArgumentException(Illega )); } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { - String message = e.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .orElse("유효성 검사 실패"); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(Map.of( - "code", "VALIDATION_ERROR", - "message", message - )); - } - @ExceptionHandler(MissingRequestHeaderException.class) public ResponseEntity> handleMissingHeaderException(MissingRequestHeaderException e) { return ResponseEntity diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java new file mode 100644 index 000000000..7f6a04dff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BrandQueryServiceTest { + + private BrandRepository brandRepository; + private BrandQueryService service; + + @BeforeEach + void setUp() { + brandRepository = mock(BrandRepository.class); + service = new BrandQueryService(brandRepository); + } + + @Nested + @DisplayName("브랜드 조회") + class QueryBrand { + + @Test + @DisplayName("단건 조회 성공") + void getBrand_success() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getBrand(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.name()).isEqualTo("나이키"); + } + + @Test + @DisplayName("존재하지 않는 브랜드 조회시 예외") + void getBrand_fail_notFound() { + // given + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getBrand(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + + @Test + @DisplayName("목록 조회 성공") + void getBrands_success() { + // given + Brand brand1 = createBrand(1L, "나이키"); + Brand brand2 = createBrand(2L, "아디다스"); + + when(brandRepository.findAllActive()).thenReturn(List.of(brand1, brand2)); + + // when + var result = service.getBrands(); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("나이키"); + } + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java new file mode 100644 index 000000000..57b407308 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -0,0 +1,132 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.model.common.DomainEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class BrandServiceTest { + + private BrandRepository brandRepository; + private DomainEventPublisher eventPublisher; + private BrandService service; + + @BeforeEach + void setUp() { + brandRepository = mock(BrandRepository.class); + eventPublisher = mock(DomainEventPublisher.class); + service = new BrandService(brandRepository, eventPublisher); + } + + @Nested + @DisplayName("브랜드 생성") + class CreateBrand { + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() { + // given + when(brandRepository.existsByName(any(BrandName.class))).thenReturn(false); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.createBrand("나이키", "스포츠 브랜드")); + + verify(brandRepository).save(any(Brand.class)); + } + + @Test + @DisplayName("중복 이름으로 생성시 예외") + void createBrand_fail_duplicateName() { + // given + when(brandRepository.existsByName(any(BrandName.class))).thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.createBrand("나이키", "스포츠 브랜드")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 존재하는 브랜드 이름"); + + verify(brandRepository, never()).save(any(Brand.class)); + } + } + + @Nested + @DisplayName("브랜드 수정") + class UpdateBrand { + + @Test + @DisplayName("브랜드 수정 성공") + void updateBrand_success() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updateBrand(1L, "아디다스", "변경된 설명")); + + verify(brandRepository).save(any(Brand.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 수정시 예외") + void updateBrand_fail_notFound() { + // given + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updateBrand(999L, "아디다스", "설명")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("브랜드 삭제") + class DeleteBrand { + + @Test + @DisplayName("브랜드 삭제 성공 - 이벤트 발행") + void deleteBrand_success_eventPublished() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); + + // when + service.deleteBrand(1L); + + // then + verify(brandRepository).save(any(Brand.class)); + verify(eventPublisher).publishEvents(any(Brand.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제시 예외") + void deleteBrand_fail_notFound() { + // given + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.deleteBrand(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java new file mode 100644 index 000000000..d30064ec4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import com.loopers.application.like.LikeProductReadPort.LikeProductView; +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LikeQueryServiceTest { + + private LikeProductReadPort likeProductReadPort; + private LikeQueryService service; + + @BeforeEach + void setUp() { + likeProductReadPort = mock(LikeProductReadPort.class); + service = new LikeQueryService(likeProductReadPort); + } + + @Nested + @DisplayName("좋아요 목록 조회") + class GetMyLikes { + + @Test + @DisplayName("좋아요 목록 조회 성공") + void getMyLikes_success() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "상품1", 10000, null, 100, "나이키", now), + new LikeProductView(2L, "상품2", 20000, null, 50, "나이키", now.minusHours(1)) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "latest", null, null); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).brandName()).isEqualTo("나이키"); + } + + @Test + @DisplayName("좋아요 목록이 비어있는 경우") + void getMyLikes_empty() { + // given + UserId userId = UserId.of("test1234"); + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(List.of()); + + // when + var result = service.getMyLikes(userId, "latest", null, null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("세일 상품만 필터링") + void getMyLikes_filterBySaleYn() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "일반상품", 10000, null, 100, "나이키", now), + new LikeProductView(2L, "세일상품", 100000, 70000, 50, "나이키", now) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "latest", true, null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).onSale()).isTrue(); + } + + @Test + @DisplayName("가격순 정렬") + void getMyLikes_sortByPrice() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "비싼상품", 100000, null, 50, "나이키", now), + new LikeProductView(2L, "싼상품", 10000, null, 50, "나이키", now) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "price_asc", null, null); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).price()).isEqualTo(10000); + assertThat(result.get(1).price()).isEqualTo(100000); + } + } +} 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..ac4e91397 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,137 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.common.DomainEventPublisher; +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.product.*; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class LikeServiceTest { + + private LikeRepository likeRepository; + private ProductRepository productRepository; + private DomainEventPublisher domainEventPublisher; + private LikeService service; + + @BeforeEach + void setUp() { + likeRepository = mock(LikeRepository.class); + productRepository = mock(ProductRepository.class); + domainEventPublisher = mock(DomainEventPublisher.class); + service = new LikeService(likeRepository, productRepository, domainEventPublisher); + } + + @Nested + @DisplayName("좋아요") + class LikeTest { + + @Test + @DisplayName("좋아요 성공") + void like_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 0); + + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); + + // when + service.like(userId, 1L); + + // then + verify(likeRepository).save(any(Like.class)); + verify(domainEventPublisher).publishEvents(any(Like.class)); + } + + @Test + @DisplayName("이미 좋아요한 경우 무시 (멱등성)") + void like_alreadyLiked_ignored() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 1); + + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); + + // when + service.like(userId, 1L); + + // then + verify(likeRepository, never()).save(any(Like.class)); + verify(domainEventPublisher, never()).publishEvents(any()); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요시 예외") + void like_fail_productNotFound() { + // given + UserId userId = UserId.of("test1234"); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.like(userId, 999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("좋아요 취소") + class UnlikeTest { + + @Test + @DisplayName("좋아요 취소 성공") + void unlike_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 1); + Like like = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); + + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.of(like)); + + // when + service.unlike(userId, 1L); + + // then + verify(domainEventPublisher).publishEvents(any(Like.class)); + verify(likeRepository).deleteByUserIdAndProductId(userId, 1L); + } + + @Test + @DisplayName("좋아요하지 않은 경우 무시") + void unlike_notLiked_ignored() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 0); + + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.empty()); + + // when + service.unlike(userId, 1L); + + // then + verify(likeRepository, never()).deleteByUserIdAndProductId(any(), any()); + verify(domainEventPublisher, never()).publishEvents(any()); + } + } + + private Product createProduct(Long id, int likeCount) { + return Product.reconstitute(new ProductData(id, 1L, ProductName.of("상품" + id), Price.of(10000), + null, Stock.of(100), likeCount, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java new file mode 100644 index 000000000..b5f756b48 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java @@ -0,0 +1,196 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class OrderQueryServiceTest { + + private OrderRepository orderRepository; + private OrderQueryService service; + + @BeforeEach + void setUp() { + orderRepository = mock(OrderRepository.class); + service = new OrderQueryService(orderRepository); + } + + @Nested + @DisplayName("내 주문 목록 조회") + class GetMyOrders { + + @Test + @DisplayName("주문 목록 조회 성공") + void getMyOrders_success() { + // given + UserId userId = UserId.of("test1234"); + Order order1 = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + Order order2 = createOrder(2L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findAllByUserId(userId)).thenReturn(List.of(order1, order2)); + + // when + var result = service.getMyOrders(userId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).id()).isEqualTo(1L); + assertThat(result.get(0).status()).isEqualTo("PAYMENT_COMPLETED"); + } + + @Test + @DisplayName("기간 필터 조회 성공") + void getMyOrders_withDateRange() { + // given + UserId userId = UserId.of("test1234"); + LocalDate start = LocalDate.of(2025, 1, 1); + LocalDate end = LocalDate.of(2025, 12, 31); + + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + when(orderRepository.findAllByUserIdAndDateRange(eq(userId), any(), any())) + .thenReturn(List.of(order)); + + // when + var result = service.getMyOrders(userId, start, end); + + // then + assertThat(result).hasSize(1); + verify(orderRepository).findAllByUserIdAndDateRange(eq(userId), any(), any()); + } + + @Test + @DisplayName("주문 없는 경우 빈 목록") + void getMyOrders_empty() { + // given + UserId userId = UserId.of("test1234"); + when(orderRepository.findAllByUserId(userId)).thenReturn(List.of()); + + // when + var result = service.getMyOrders(userId); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("주문 상세 조회") + class GetOrder { + + @Test + @DisplayName("내 주문 상세 조회 성공") + void getOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + var result = service.getOrder(userId, 1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.receiverName()).isEqualTo("홍길동"); + assertThat(result.status()).isEqualTo("PAYMENT_COMPLETED"); + assertThat(result.items()).hasSize(1); + } + + @Test + @DisplayName("다른 사용자 주문 조회시 예외") + void getOrder_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.getOrder(userId, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("존재하지 않는 주문 조회시 예외") + void getOrder_fail_notFound() { + // given + UserId userId = UserId.of("test1234"); + when(orderRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getOrder(userId, 999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("관리자 주문 조회") + class AdminQuery { + + @Test + @DisplayName("전체 주문 목록 조회") + void getAllOrders_success() { + // given + UserId user1 = UserId.of("user0001"); + UserId user2 = UserId.of("user0002"); + Order order1 = createOrder(1L, user1, OrderStatus.PAYMENT_COMPLETED); + Order order2 = createOrder(2L, user2, OrderStatus.SHIPPING); + + when(orderRepository.findAll()).thenReturn(List.of(order1, order2)); + + // when + var result = service.getAllOrders(); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("관리자 주문 상세 조회 (userId 검증 없음)") + void getOrderDetail_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.DELIVERED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + var result = service.getOrderDetail(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.status()).isEqualTo("DELIVERED"); + } + } + + private Order createOrder(Long id, UserId userId, OrderStatus status) { + List items = List.of( + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + ); + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시 강남구", + "배송 요청", LocalDate.now().plusDays(3)); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(100000), Money.zero(), Money.of(100000)); + return Order.reconstitute(new OrderData(id, userId, items, null, + deliveryInfo, orderAmount, status, + LocalDateTime.now(), LocalDateTime.now())); + } +} 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..e3409979a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,234 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.ProductData; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import com.loopers.domain.repository.ProductRepository; +import com.loopers.domain.model.common.DomainEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OrderServiceTest { + + private OrderRepository orderRepository; + private ProductRepository productRepository; + private DomainEventPublisher eventPublisher; + private OrderService service; + + @BeforeEach + void setUp() { + orderRepository = mock(OrderRepository.class); + productRepository = mock(ProductRepository.class); + eventPublisher = mock(DomainEventPublisher.class); + service = new OrderService(orderRepository, productRepository, eventPublisher); + } + + @Nested + @DisplayName("주문 생성") + class CreateOrder { + + @Test + @DisplayName("주문 생성 성공") + void createOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 50000, 100); + when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + when(productRepository.save(any(Product.class))).thenReturn(product); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(1L, 2)), + "홍길동", + "서울시 강남구", + "문 앞에 놓아주세요", + "CARD", + LocalDate.now().plusDays(3) + ); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.createOrder(userId, command)); + + verify(productRepository).save(any(Product.class)); + verify(orderRepository).save(any(Order.class)); + } + + @Test + @DisplayName("존재하지 않는 상품으로 주문시 예외") + void createOrder_fail_productNotFound() { + // given + UserId userId = UserId.of("test1234"); + when(productRepository.findActiveByIdWithLock(999L)).thenReturn(Optional.empty()); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(999L, 1)), + "홍길동", "서울시", "요청사항", "CARD", LocalDate.now() + ); + + // when & then + assertThatThrownBy(() -> service.createOrder(userId, command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + + @Test + @DisplayName("재고 부족시 예외") + void createOrder_fail_insufficientStock() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 50000, 1); + when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(1L, 100)), + "홍길동", "서울시", "요청사항", "CARD", LocalDate.now() + ); + + // when & then + assertThatThrownBy(() -> service.createOrder(userId, command)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + } + + @Nested + @DisplayName("주문 취소") + class CancelOrder { + + @Test + @DisplayName("주문 취소 성공 - 이벤트 발행") + void cancelOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + service.cancelOrder(userId, 1L); + + // then + verify(orderRepository).save(any(Order.class)); + verify(eventPublisher).publishEvents(any(Order.class)); + } + + @Test + @DisplayName("다른 사용자 주문 취소시 예외") + void cancelOrder_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.cancelOrder(userId, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("배송중 주문 취소시 예외") + void cancelOrder_fail_shipping() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.cancelOrder(userId, 1L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("주문을 취소할 수 없습니다"); + } + } + + @Nested + @DisplayName("배송지 변경") + class UpdateDeliveryAddress { + + @Test + @DisplayName("배송지 변경 성공") + void updateDeliveryAddress_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새로운 주소")); + + verify(orderRepository).save(any(Order.class)); + } + + @Test + @DisplayName("다른 사용자 주문 배송지 변경시 예외") + void updateDeliveryAddress_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새 주소")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("배송중 주문 배송지 변경시 예외") + void updateDeliveryAddress_fail_shipping() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새 주소")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("배송지를 변경할 수 없습니다"); + } + } + + private Product createProduct(Long id, int price, int stock) { + return Product.reconstitute(new ProductData(id, 1L, ProductName.of("상품" + id), Price.of(price), + null, Stock.of(stock), 0, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } + + private Order createOrder(Long id, UserId userId, OrderStatus status) { + List items = List.of( + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + ); + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시 강남구", + "문 앞에 놓아주세요", LocalDate.now().plusDays(3)); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(100000), Money.zero(), Money.of(100000)); + return Order.reconstitute(new OrderData(id, userId, items, null, + deliveryInfo, orderAmount, status, + LocalDateTime.now(), LocalDateTime.now())); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java new file mode 100644 index 000000000..ab0fa30cb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java @@ -0,0 +1,154 @@ +package com.loopers.application.product; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.product.*; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ProductQueryServiceTest { + + private ProductRepository productRepository; + private BrandRepository brandRepository; + private ProductQueryService service; + + @BeforeEach + void setUp() { + productRepository = mock(ProductRepository.class); + brandRepository = mock(BrandRepository.class); + service = new ProductQueryService(productRepository, brandRepository); + } + + @Nested + @DisplayName("상품 단건 조회") + class GetProduct { + + @Test + @DisplayName("상품 상세 조회 성공") + void getProduct_success() { + // given + Product product = createProduct(1L, 1L, "운동화", 50000); + Brand brand = createBrand(1L, "나이키"); + + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getProduct(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.brandName()).isEqualTo("나이키"); + assertThat(result.name()).isEqualTo("운동화"); + assertThat(result.price()).isEqualTo(50000); + } + + @Test + @DisplayName("삭제된 상품 조회시 예외") + void getProduct_fail_deleted() { + // given + when(productRepository.findActiveById(1L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getProduct(1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + + @Test + @DisplayName("존재하지 않는 상품 조회시 예외") + void getProduct_fail_notFound() { + // given + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getProduct(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("상품 목록 조회") + class GetProducts { + + @Test + @DisplayName("전체 목록 조회 성공") + void getProducts_success() { + // given + Product product1 = createProduct(1L, 1L, "운동화", 50000); + Product product2 = createProduct(2L, 1L, "슬리퍼", 30000); + Brand brand = createBrand(1L, "나이키"); + + PageResult pageResult = new PageResult<>(List.of(product1, product2), 0, 20, 2, 1); + when(productRepository.findAllActive(eq(null), eq(null), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); + + // when + var result = service.getProducts(null, null, 0, 20); + + // then + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).brandName()).isEqualTo("나이키"); + } + + @Test + @DisplayName("브랜드 필터 조회") + void getProducts_withBrandFilter() { + // given + Product product = createProduct(1L, 1L, "운동화", 50000); + Brand brand = createBrand(1L, "나이키"); + + PageResult pageResult = new PageResult<>(List.of(product), 0, 20, 1, 1); + when(productRepository.findAllActive(eq(1L), eq(null), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); + + // when + var result = service.getProducts(1L, null, 0, 20); + + // then + assertThat(result.content()).hasSize(1); + } + + @Test + @DisplayName("가격 오름차순 정렬") + void getProducts_sortByPriceAsc() { + // given + PageResult pageResult = new PageResult<>(List.of(), 0, 20, 0, 0); + when(productRepository.findAllActive(eq(null), eq("price_asc"), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of())).thenReturn(List.of()); + + // when + service.getProducts(null, "price_asc", 0, 20); + + // then + verify(productRepository).findAllActive(eq(null), eq("price_asc"), eq(0), eq(20)); + } + } + + private Product createProduct(Long id, Long brandId, String name, int price) { + return Product.reconstitute(new ProductData(id, brandId, ProductName.of(name), Price.of(price), + null, Stock.of(100), 5, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java new file mode 100644 index 000000000..e6d02e4fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -0,0 +1,158 @@ +package com.loopers.application.product; + +import com.loopers.application.product.CreateProductUseCase.ProductCreateCommand; +import com.loopers.application.product.UpdateProductUseCase.ProductUpdateCommand; +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.product.*; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ProductServiceTest { + + private ProductRepository productRepository; + private BrandRepository brandRepository; + private ProductService service; + + @BeforeEach + void setUp() { + productRepository = mock(ProductRepository.class); + brandRepository = mock(BrandRepository.class); + service = new ProductService(productRepository, brandRepository); + } + + @Nested + @DisplayName("상품 생성") + class CreateProduct { + + @Test + @DisplayName("상품 생성 성공") + void createProduct_success() { + // given + Brand brand = createBrand(1L); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); + + // when & then + var command = new ProductCreateCommand(1L, "운동화", 50000, null, 100, "좋은 운동화"); + assertThatNoException() + .isThrownBy(() -> service.createProduct(command)); + + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성시 예외") + void createProduct_fail_brandNotFound() { + // given + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + var command = new ProductCreateCommand(999L, "운동화", 50000, null, 100, "좋은 운동화"); + assertThatThrownBy(() -> service.createProduct(command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 브랜드"); + + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("삭제된 브랜드로 생성시 예외") + void createProduct_fail_deletedBrand() { + // given + when(brandRepository.findActiveById(1L)).thenReturn(Optional.empty()); + + // when & then + var command = new ProductCreateCommand(1L, "운동화", 50000, null, 100, "좋은 운동화"); + assertThatThrownBy(() -> service.createProduct(command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 브랜드"); + } + } + + @Nested + @DisplayName("상품 수정") + class UpdateProduct { + + @Test + @DisplayName("상품 수정 성공") + void updateProduct_success() { + // given + Product product = createProduct(1L, 1L); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + + // when & then + var command = new ProductUpdateCommand(1L, "새 이름", 60000, null, 200, "변경된 설명"); + assertThatNoException() + .isThrownBy(() -> service.updateProduct(command)); + + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 수정시 예외") + void updateProduct_fail_notFound() { + // given + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + var command = new ProductUpdateCommand(999L, "새 이름", 60000, null, 200, "설명"); + assertThatThrownBy(() -> service.updateProduct(command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("상품 삭제") + class DeleteProduct { + + @Test + @DisplayName("상품 삭제 성공") + void deleteProduct_success() { + // given + Product product = createProduct(1L, 1L); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); + + // when + service.deleteProduct(1L); + + // then + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제시 예외") + void deleteProduct_fail_notFound() { + // given + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.deleteProduct(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + private Brand createBrand(Long id) { + return Brand.reconstitute(new BrandData(id, BrandName.of("나이키"), "스포츠 브랜드", + LocalDateTime.now(), LocalDateTime.now(), null)); + } + + private Product createProduct(Long id, Long brandId) { + return Product.reconstitute(new ProductData(id, brandId, ProductName.of("운동화"), Price.of(50000), + null, Stock.of(100), 0, "좋은 운동화", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java similarity index 89% rename from apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java index 39af9240a..ff66f043f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java @@ -1,6 +1,6 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.junit.jupiter.api.BeforeEach; @@ -61,7 +61,7 @@ void authenticate_fail_userNotFound() { // when & then assertThatThrownBy(() -> service.authenticate(userId, "password")) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); + .hasMessageContaining("아이디 또는 비밀번호가 올바르지 않습니다"); } @Test @@ -79,19 +79,19 @@ void authenticate_fail_passwordMismatch() { // when & then assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호가 일치하지 않습니다"); + .hasMessageContaining("아이디 또는 비밀번호가 올바르지 않습니다"); } private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( + return User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), encodedPassword, Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java similarity index 92% rename from apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java index 6e62421b6..bac328884 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java @@ -1,6 +1,7 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.domain.model.*; +import com.loopers.application.user.RegisterUseCase.RegisterCommand; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.junit.jupiter.api.BeforeEach; @@ -49,8 +50,9 @@ void register_success() { when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); // when & then + var command = new RegisterCommand(loginId, name, rawPassword, BIRTHDAY, email); assertThatNoException() - .isThrownBy(() -> service.register(loginId, name, rawPassword, BIRTHDAY, email)); + .isThrownBy(() -> service.register(command)); verify(passwordEncoder).encrypt(rawPassword); verify(userRepository).save(any(User.class)); @@ -70,7 +72,8 @@ void register_fail_duplicated_id() { .when(userRepository).save(any(User.class)); // when & then - assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, BIRTHDAY, email)) + var command = new RegisterCommand(duplicatedId, name, rawPassword, BIRTHDAY, email); + assertThatThrownBy(() -> service.register(command)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("이미 사용중인 ID"); } @@ -165,16 +168,16 @@ class UserQuery { void getUserInfo_success() { // given UserId userId = UserId.of("test1234"); - User user = User.reconstitute( + User user = User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), "encoded_password", Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); @@ -193,16 +196,16 @@ void getUserInfo_success() { void getUserInfo_maskedName_2chars() { // given UserId userId = UserId.of("test1234"); - User user = User.reconstitute( + User user = User.reconstitute(new UserData( 1L, userId, UserName.of("홍길"), "encoded_password", Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); @@ -237,15 +240,15 @@ void userName_fail_lessThan2chars() { } private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( + return User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), encodedPassword, Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java deleted file mode 100644 index 88c2bfe5e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.loopers.domain.model; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class WrongPasswordCountTest { - - @Test - @DisplayName("초기값 0으로 생성") - void init_success() { - // given and when - WrongPasswordCount count = WrongPasswordCount.init(); - - // then - assertThat(count.getValue()).isEqualTo(0); - } - - @Test - @DisplayName("유효한 값으로 생성") - void of_success() { - // given - int value = 3; - - // when - WrongPasswordCount count = WrongPasswordCount.of(value); - - // then - assertThat(count.getValue()).isEqualTo(3); - } - - @Test - @DisplayName("음수값이면 예외") - void of_fail_negative() { - // given - int value = -1; - - // when and then - assertThatThrownBy(() -> WrongPasswordCount.of(value)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("음수"); - } - - @Test - @DisplayName("카운트 증가") - void increment_success() { - // given - WrongPasswordCount count = WrongPasswordCount.init(); - - // when - WrongPasswordCount incremented = count.increment(); - - // then - assertThat(incremented.getValue()).isEqualTo(1); - } - - @Test - @DisplayName("카운트 리셋") - void reset_success() { - // given - WrongPasswordCount count = WrongPasswordCount.of(3); - - // when - WrongPasswordCount reset = count.reset(); - - // then - assertThat(reset.getValue()).isEqualTo(0); - } - - @Test - @DisplayName("5회 이상 실패시 잠금") - void isLocked_true() { - // given - WrongPasswordCount count = WrongPasswordCount.of(5); - - // when - boolean locked = count.isLocked(); - - // then - assertThat(locked).isTrue(); - } - - @Test - @DisplayName("5회 미만 실패시 잠금 안됨") - void isLocked_false() { - // given - WrongPasswordCount count = WrongPasswordCount.of(4); - - // when - boolean locked = count.isLocked(); - - // then - assertThat(locked).isFalse(); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java new file mode 100644 index 000000000..f8341215f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java @@ -0,0 +1,57 @@ +package com.loopers.domain.model.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandNameTest { + + @Test + @DisplayName("유효한 브랜드 이름 생성 성공") + void create_success() { + BrandName name = BrandName.of("Nike"); + assertThat(name.getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("브랜드 이름 null이면 예외") + void create_fail_null() { + assertThatThrownBy(() -> BrandName.of(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("브랜드 이름 공백이면 예외") + void create_fail_blank() { + assertThatThrownBy(() -> BrandName.of(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("브랜드 이름 50자 초과면 예외") + void create_fail_too_long() { + String longName = "a".repeat(51); + assertThatThrownBy(() -> BrandName.of(longName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1~50자"); + } + + @Test + @DisplayName("브랜드 이름 공백 trim 처리") + void create_success_with_trim() { + BrandName name = BrandName.of(" Nike "); + assertThat(name.getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("동일한 이름은 equals 동등") + void equals_consistency() { + BrandName name1 = BrandName.of("Nike"); + BrandName name2 = BrandName.of("Nike"); + assertThat(name1).isEqualTo(name2); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java new file mode 100644 index 000000000..02d38dc43 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.model.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandTest { + + @Test + @DisplayName("브랜드 생성 성공") + void create_success() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + + assertThat(brand.getId()).isNull(); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + assertThat(brand.getCreatedAt()).isNotNull(); + assertThat(brand.isDeleted()).isFalse(); + } + + @Test + @DisplayName("브랜드 수정 시 새 객체 반환") + void update_returns_new_instance() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand updated = brand.update(BrandName.of("Adidas"), "독일 스포츠 브랜드"); + + assertThat(updated.getName().getValue()).isEqualTo("Adidas"); + assertThat(updated.getDescription()).isEqualTo("독일 스포츠 브랜드"); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("브랜드 삭제 시 deletedAt 설정") + void delete_success() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand deleted = brand.delete(); + + assertThat(deleted.isDeleted()).isTrue(); + assertThat(deleted.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 브랜드 재삭제 시 예외") + void delete_already_deleted() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand deleted = brand.delete(); + + assertThatThrownBy(deleted::delete) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 삭제된 브랜드입니다."); + } + + @Test + @DisplayName("reconstitute로 DB에서 복원") + void reconstitute_success() { + LocalDateTime now = LocalDateTime.now(); + Brand brand = Brand.reconstitute(new BrandData(1L, BrandName.of("Nike"), "스포츠 브랜드", now, now, null)); + + assertThat(brand.getId()).isEqualTo(1L); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + assertThat(brand.isDeleted()).isFalse(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java new file mode 100644 index 000000000..6f91d2d96 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.model.like; + +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeTest { + + @Test + @DisplayName("좋아요 생성 성공") + void create_success() { + UserId userId = UserId.of("testuser1"); + Like like = Like.create(userId, 1L); + + assertThat(like.getId()).isNull(); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(1L); + assertThat(like.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("userId null이면 예외") + void create_fail_null_userId() { + assertThatThrownBy(() -> Like.create(null, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("productId null이면 예외") + void create_fail_null_productId() { + assertThatThrownBy(() -> Like.create(UserId.of("testuser1"), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("reconstitute로 DB에서 복원") + void reconstitute_success() { + LocalDateTime now = LocalDateTime.now(); + UserId userId = UserId.of("testuser1"); + Like like = Like.reconstitute(1L, userId, 100L, now); + + assertThat(like.getId()).isEqualTo(1L); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(100L); + assertThat(like.getCreatedAt()).isEqualTo(now); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java new file mode 100644 index 000000000..921f9fbec --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java @@ -0,0 +1,79 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DeliveryInfoTest { + + @Test + @DisplayName("DeliveryInfo 생성 성공") + void of_success() { + DeliveryInfo info = DeliveryInfo.of( + "홍길동", + "서울시 강남구", + "문 앞에 놓아주세요", + LocalDate.of(2025, 6, 15) + ); + + assertThat(info.getReceiverName()).isEqualTo("홍길동"); + assertThat(info.getAddress()).isEqualTo("서울시 강남구"); + assertThat(info.getDeliveryRequest()).isEqualTo("문 앞에 놓아주세요"); + assertThat(info.getDesiredDeliveryDate()).isEqualTo(LocalDate.of(2025, 6, 15)); + } + + @Test + @DisplayName("배송 요청사항과 희망 배송일은 nullable") + void of_nullable_fields() { + DeliveryInfo info = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + + assertThat(info.getDeliveryRequest()).isNull(); + assertThat(info.getDesiredDeliveryDate()).isNull(); + } + + @Test + @DisplayName("수령인 이름이 null이면 예외") + void of_fail_null_receiverName() { + assertThatThrownBy(() -> DeliveryInfo.of(null, "서울시", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("수령인 이름은 필수입니다"); + } + + @Test + @DisplayName("배송 주소가 null이면 예외") + void of_fail_null_address() { + assertThatThrownBy(() -> DeliveryInfo.of("홍길동", null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("배송 주소는 필수입니다"); + } + + @Test + @DisplayName("withAddress로 새 배송지 반환 - 불변 객체") + void withAddress_returns_new_instance() { + DeliveryInfo original = DeliveryInfo.of( + "홍길동", + "서울시", + "요청사항", + LocalDate.of(2025, 6, 15) + ); + + DeliveryInfo updated = original.withAddress("부산시"); + + assertThat(updated.getAddress()).isEqualTo("부산시"); + assertThat(updated.getReceiverName()).isEqualTo("홍길동"); + assertThat(updated.getDeliveryRequest()).isEqualTo("요청사항"); + assertThat(updated.getDesiredDeliveryDate()).isEqualTo(LocalDate.of(2025, 6, 15)); + + // 원본 불변 확인 + assertThat(original.getAddress()).isEqualTo("서울시"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java new file mode 100644 index 000000000..fb58c87c8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MoneyTest { + + @Test + @DisplayName("유효한 금액 생성 성공") + void create_success() { + Money money = Money.of(10000); + assertThat(money.getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("0원 생성 성공") + void create_success_zero() { + Money money = Money.of(0); + assertThat(money.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 금액 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Money.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("금액 덧셈") + void add() { + Money a = Money.of(1000); + Money b = Money.of(2000); + assertThat(a.add(b).getValue()).isEqualTo(3000); + } + + @Test + @DisplayName("금액 뺄셈") + void subtract() { + Money a = Money.of(3000); + Money b = Money.of(1000); + assertThat(a.subtract(b).getValue()).isEqualTo(2000); + } + + @Test + @DisplayName("금액 뺄셈 결과 음수면 예외") + void subtract_fail_negative_result() { + Money a = Money.of(1000); + Money b = Money.of(2000); + assertThatThrownBy(() -> a.subtract(b)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("음수"); + } + + @Test + @DisplayName("금액 곱셈") + void multiply() { + Money money = Money.of(5000); + assertThat(money.multiply(3).getValue()).isEqualTo(15000); + } + + @Test + @DisplayName("금액 곱셈 음수 수량이면 예외") + void multiply_fail_negative() { + Money money = Money.of(5000); + assertThatThrownBy(() -> money.multiply(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("zero 팩토리 메서드") + void zero() { + assertThat(Money.zero().getValue()).isEqualTo(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java new file mode 100644 index 000000000..d20e6322f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderAmountTest { + + @Test + @DisplayName("of() - paymentAmount 자동 계산 (totalAmount - discountAmount)") + void of_auto_calculate_paymentAmount() { + OrderAmount amount = OrderAmount.of(PaymentMethod.CARD, Money.of(50000), Money.of(5000)); + + assertThat(amount.getPaymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(amount.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(45000); + } + + @Test + @DisplayName("of() - discountAmount가 null이면 0원 처리") + void of_null_discount_defaults_to_zero() { + OrderAmount amount = OrderAmount.of(PaymentMethod.BANK_TRANSFER, Money.of(30000), null); + + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(0); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(30000); + } + + @Test + @DisplayName("of() - paymentMethod null이면 예외") + void of_fail_null_paymentMethod() { + assertThatThrownBy(() -> OrderAmount.of(null, Money.of(10000), Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("결제 수단은 필수입니다"); + } + + @Test + @DisplayName("of() - totalAmount null이면 예외") + void of_fail_null_totalAmount() { + assertThatThrownBy(() -> OrderAmount.of(PaymentMethod.CARD, null, Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("총 금액은 필수입니다"); + } + + @Test + @DisplayName("reconstitute() - 저장된 값 그대로 복원") + void reconstitute_preserves_stored_values() { + OrderAmount amount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(50000), Money.of(5000), Money.of(45000)); + + assertThat(amount.getPaymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(amount.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(45000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java new file mode 100644 index 000000000..3f7e971fe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderItemTest { + + @Test + @DisplayName("주문 항목 생성 성공") + void create_success() { + OrderItem item = OrderItem.create(1L, 2, Money.of(10000)); + + assertThat(item.getId()).isNull(); + assertThat(item.getProductId()).isEqualTo(1L); + assertThat(item.getQuantity()).isEqualTo(2); + assertThat(item.getUnitPrice().getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("productId null이면 예외") + void create_fail_null_productId() { + assertThatThrownBy(() -> OrderItem.create(null, 2, Money.of(10000))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("수량 0 이하면 예외") + void create_fail_zero_quantity() { + assertThatThrownBy(() -> OrderItem.create(1L, 0, Money.of(10000))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 이상"); + } + + @Test + @DisplayName("단가 null이면 예외") + void create_fail_null_unitPrice() { + assertThatThrownBy(() -> OrderItem.create(1L, 2, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("금액 계산 (단가 * 수량)") + void calculateAmount() { + OrderItem item = OrderItem.create(1L, 3, Money.of(10000)); + assertThat(item.calculateAmount().getValue()).isEqualTo(30000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java new file mode 100644 index 000000000..3ff6d9185 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java @@ -0,0 +1,191 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + private Order createOrder() { + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(10000), 2), + new OrderLine(2L, "상품B", Money.of(20000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시 강남구", + "부재시 문 앞에 놓아주세요", + LocalDate.now().plusDays(3) + ); + + return Order.create( + UserId.of("testuser1"), + orderLines, + deliveryInfo, + PaymentMethod.CARD, + Money.zero() + ); + } + + @Test + @DisplayName("주문 생성 성공 - 금액 자동 계산") + void create_success() { + Order order = createOrder(); + + assertThat(order.getId()).isNull(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_COMPLETED); + assertThat(order.getTotalAmount().getValue()).isEqualTo(40000); // 10000*2 + 20000*1 + assertThat(order.getPaymentAmount().getValue()).isEqualTo(40000); + assertThat(order.getItems()).hasSize(2); + assertThat(order.getSnapshot()).isNotNull(); + } + + @Test + @DisplayName("주문 생성 - 할인 적용") + void create_with_discount() { + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(50000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + + Order order = Order.create( + UserId.of("testuser1"), orderLines, + deliveryInfo, PaymentMethod.CARD, + Money.of(5000) + ); + + assertThat(order.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(order.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(order.getPaymentAmount().getValue()).isEqualTo(45000); + } + + @Test + @DisplayName("userId null이면 예외") + void create_fail_null_userId() { + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(10000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + + assertThatThrownBy(() -> Order.create(null, orderLines, + deliveryInfo, PaymentMethod.CARD, Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 항목 비어있으면 예외") + void create_fail_empty_items() { + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + + assertThatThrownBy(() -> Order.create(UserId.of("testuser1"), List.of(), + deliveryInfo, PaymentMethod.CARD, Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1개 이상"); + } + + @Test + @DisplayName("PAYMENT_COMPLETED 상태에서 취소 가능") + void cancel_success() { + Order order = createOrder(); + assertThat(order.isCancellable()).isTrue(); + + Order cancelled = order.cancel(); + assertThat(cancelled.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(cancelled.getDomainEvents()).hasSize(1); + } + + @Test + @DisplayName("SHIPPING 상태에서 취소 불가") + void cancel_fail_shipping() { + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.SHIPPING, LocalDateTime.now(), LocalDateTime.now() + )); + + assertThat(order.isCancellable()).isFalse(); + assertThatThrownBy(order::cancel) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("취소할 수 없습니다"); + } + + @Test + @DisplayName("DELIVERED 상태에서 취소 불가") + void cancel_fail_delivered() { + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.DELIVERED, LocalDateTime.now(), LocalDateTime.now() + )); + + assertThatThrownBy(order::cancel) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("배송지 변경 성공 (PAYMENT_COMPLETED)") + void updateDeliveryAddress_success() { + Order order = createOrder(); + Order updated = order.updateDeliveryAddress("부산시 해운대구"); + + assertThat(updated.getAddress()).isEqualTo("부산시 해운대구"); + } + + @Test + @DisplayName("SHIPPING 상태에서 배송지 변경 불가") + void updateDeliveryAddress_fail_shipping() { + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.SHIPPING, LocalDateTime.now(), LocalDateTime.now() + )); + + assertThatThrownBy(() -> order.updateDeliveryAddress("부산시")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("배송지를 변경할 수 없습니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java new file mode 100644 index 000000000..55c109a12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java @@ -0,0 +1,32 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceTest { + + @Test + @DisplayName("유효한 가격 생성 성공") + void create_success() { + Price price = Price.of(10000); + assertThat(price.getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("가격 0원 생성 성공") + void create_success_zero() { + Price price = Price.of(0); + assertThat(price.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 가격 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Price.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java new file mode 100644 index 000000000..f541ccc75 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductNameTest { + + @Test + @DisplayName("유효한 상품 이름 생성 성공") + void create_success() { + ProductName name = ProductName.of("에어맥스 90"); + assertThat(name.getValue()).isEqualTo("에어맥스 90"); + } + + @Test + @DisplayName("상품 이름 null이면 예외") + void create_fail_null() { + assertThatThrownBy(() -> ProductName.of(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("상품 이름 100자 초과면 예외") + void create_fail_too_long() { + String longName = "a".repeat(101); + assertThatThrownBy(() -> ProductName.of(longName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("100자"); + } + + @Test + @DisplayName("상품 이름 공백 trim 처리") + void create_success_with_trim() { + ProductName name = ProductName.of(" 에어맥스 90 "); + assertThat(name.getValue()).isEqualTo("에어맥스 90"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java new file mode 100644 index 000000000..a16879818 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductPricingTest { + + @Test + @DisplayName("정상가만 있는 경우 세일 아님") + void notOnSale() { + ProductPricing pricing = ProductPricing.of(Price.of(10000), null); + + assertThat(pricing.isOnSale()).isFalse(); + assertThat(pricing.getDiscountRate()).isEqualTo(0); + } + + @Test + @DisplayName("세일가 있는 경우 할인율 계산") + void onSale_withDiscountRate() { + ProductPricing pricing = ProductPricing.of(Price.of(139000), Price.of(99000)); + + assertThat(pricing.isOnSale()).isTrue(); + assertThat(pricing.getDiscountRate()).isEqualTo(28); + } + + @Test + @DisplayName("가격 필수 검증") + void price_required() { + assertThatThrownBy(() -> ProductPricing.of(null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품 가격은 필수입니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java new file mode 100644 index 000000000..6ec26de2f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + private Product createProduct() { + return Product.create( + 1L, + ProductName.of("에어맥스 90"), + Price.of(139000), + null, + Stock.of(50), + "나이키 에어맥스 90" + ); + } + + @Test + @DisplayName("상품 생성 성공") + void create_success() { + Product product = createProduct(); + + assertThat(product.getId()).isNull(); + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName().getValue()).isEqualTo("에어맥스 90"); + assertThat(product.getPrice().getValue()).isEqualTo(139000); + assertThat(product.getStock().getValue()).isEqualTo(50); + assertThat(product.getLikeCount()).isEqualTo(0); + assertThat(product.isDeleted()).isFalse(); + assertThat(product.isOnSale()).isFalse(); + assertThat(product.getDiscountRate()).isEqualTo(0); + } + + @Test + @DisplayName("세일 상품 생성") + void create_withSalePrice() { + Product product = Product.create( + 1L, + ProductName.of("에어맥스 90"), + Price.of(139000), + Price.of(99000), + Stock.of(50), + "나이키 에어맥스 90" + ); + + assertThat(product.isOnSale()).isTrue(); + assertThat(product.getSalePrice().getValue()).isEqualTo(99000); + assertThat(product.getDiscountRate()).isEqualTo(28); // (139000-99000)*100/139000 = 28 + } + + @Test + @DisplayName("상품 수정 시 brandId 변경 불가 (update에 brandId 파라미터 없음)") + void update_without_brandId() { + Product product = createProduct(); + Product updated = product.update( + ProductName.of("에어맥스 95"), + Price.of(159000), + null, + Stock.of(30), + "나이키 에어맥스 95" + ); + + assertThat(updated.getBrandId()).isEqualTo(1L); + assertThat(updated.getName().getValue()).isEqualTo("에어맥스 95"); + assertThat(updated.getPrice().getValue()).isEqualTo(159000); + } + + @Test + @DisplayName("상품 삭제 (Soft Delete)") + void delete_success() { + Product product = createProduct(); + Product deleted = product.delete(); + + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상품 재삭제 시 예외") + void delete_already_deleted() { + Product product = createProduct(); + Product deleted = product.delete(); + + assertThatThrownBy(deleted::delete) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 삭제된 상품입니다."); + } + + @Test + @DisplayName("재고 차감 성공") + void decreaseStock_success() { + Product product = createProduct(); + Product decreased = product.decreaseStock(5); + + assertThat(decreased.getStock().getValue()).isEqualTo(45); + } + + @Test + @DisplayName("재고 부족 시 차감 예외") + void decreaseStock_fail_insufficient() { + Product product = createProduct(); + + assertThatThrownBy(() -> product.decreaseStock(51)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("좋아요 수 증가") + void increaseLikeCount() { + Product product = createProduct(); + Product liked = product.increaseLikeCount(); + + assertThat(liked.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("좋아요 수 감소") + void decreaseLikeCount() { + Product product = createProduct().increaseLikeCount(); + Product unliked = product.decreaseLikeCount(); + + assertThat(unliked.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 0에서 감소 시 예외") + void decreaseLikeCount_fail_zero() { + Product product = createProduct(); + + assertThatThrownBy(product::decreaseLikeCount) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("0 미만"); + } + + @Test + @DisplayName("품절 여부 확인") + void isSoldOut() { + Product soldOut = Product.create(1L, ProductName.of("품절상품"), Price.of(10000), null, + Stock.of(0), "설명"); + Product inStock = createProduct(); + + assertThat(soldOut.isSoldOut()).isTrue(); + assertThat(inStock.isSoldOut()).isFalse(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java new file mode 100644 index 000000000..12612e898 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Test + @DisplayName("유효한 재고 생성 성공") + void create_success() { + Stock stock = Stock.of(10); + assertThat(stock.getValue()).isEqualTo(10); + } + + @Test + @DisplayName("재고 0 생성 성공") + void create_success_zero() { + Stock stock = Stock.of(0); + assertThat(stock.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 재고 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Stock.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("재고 차감 성공") + void decrease_success() { + Stock stock = Stock.of(10); + Stock decreased = stock.decrease(3); + assertThat(decreased.getValue()).isEqualTo(7); + } + + @Test + @DisplayName("재고 전량 차감 성공") + void decrease_to_zero() { + Stock stock = Stock.of(5); + Stock decreased = stock.decrease(5); + assertThat(decreased.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("재고 부족 시 차감 예외") + void decrease_fail_insufficient() { + Stock stock = Stock.of(3); + assertThatThrownBy(() -> stock.decrease(5)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("차감 수량 0 이하면 예외") + void decrease_fail_zero_quantity() { + Stock stock = Stock.of(10); + assertThatThrownBy(() -> stock.decrease(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 이상"); + } + + @Test + @DisplayName("재고 충분 여부 확인") + void hasEnough() { + Stock stock = Stock.of(5); + assertThat(stock.hasEnough(5)).isTrue(); + assertThat(stock.hasEnough(6)).isFalse(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java index a2e0ff907..dd1127ed6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java index 3393a5c1d..387c900a6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java index d85452e5f..f09025f4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java index 938ec0bfa..ccb4af72e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java similarity index 78% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java index 2a8c28ee7..87f18d0cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; @@ -82,4 +82,24 @@ void create_fail_special_char() { .hasMessageContaining("한글 또는 영문"); } + @Test + @DisplayName("2자 이름 마스킹") + void maskedValue_2chars() { + UserName name = UserName.of("홍길"); + assertThat(name.maskedValue()).isEqualTo("홍*"); + } + + @Test + @DisplayName("3자 이름 마스킹") + void maskedValue_3chars() { + UserName name = UserName.of("홍길동"); + assertThat(name.maskedValue()).isEqualTo("홍길*"); + } + + @Test + @DisplayName("영문 이름 마스킹") + void maskedValue_english() { + UserName name = UserName.of("John"); + assertThat(name.maskedValue()).isEqualTo("Joh*"); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java new file mode 100644 index 000000000..eeb78c984 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java @@ -0,0 +1,73 @@ +package com.loopers.domain.model.user; + +import com.loopers.domain.service.PasswordEncoder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UserTest { + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @Test + @DisplayName("비밀번호 변경 성공") + void changePassword_success() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + when(encoder.matches("NewPass1!", "encoded_current")).thenReturn(false); + when(encoder.encrypt("NewPass1!")).thenReturn("encoded_new"); + + User updated = user.changePassword("Current1!", "NewPass1!", encoder); + + assertThat(updated.getEncodedPassword()).isEqualTo("encoded_new"); + } + + @Test + @DisplayName("현재 비밀번호 불일치시 예외") + void changePassword_fail_wrongCurrent() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("WrongPw1!", "encoded_current")).thenReturn(false); + + assertThatThrownBy(() -> user.changePassword("WrongPw1!", "NewPass1!", encoder)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면 예외") + void changePassword_fail_samePassword() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + + assertThatThrownBy(() -> user.changePassword("Current1!", "Current1!", encoder)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); + } + + private User createUser(String encodedPassword) { + return User.reconstitute(new UserData( + 1L, + UserId.of("test1234"), + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + 0, + LocalDateTime.now() + )); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java new file mode 100644 index 000000000..e542c199a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -0,0 +1,156 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandResponse; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class BrandApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_BASE_URL = "/api-admin/v1/brands"; + private static final String PUBLIC_BASE_URL = "/api/v1/brands"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 브랜드 CRUD 시나리오") + class BrandCrudE2E { + + @Test + @DisplayName("브랜드 생성 → 조회 성공") + void create_then_get() { + // given + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + // when - 생성 + ResponseEntity createResponse = restTemplate.exchange( + ADMIN_BASE_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + + // then + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + BrandResponse.class + ); + + // then + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).isNotNull(); + assertThat(getResponse.getBody().name()).isEqualTo("나이키"); + } + + @Test + @DisplayName("브랜드 생성 → 수정 → 조회 확인") + void create_update_then_get() { + // given + createBrand("나이키", "원래 설명"); + + // when - 수정 + var updateRequest = new BrandUpdateRequest("아디다스", "변경된 설명"); + ResponseEntity updateResponse = restTemplate.exchange( + ADMIN_BASE_URL + "/1", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, createAdminHeaders()), + Void.class + ); + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + BrandResponse.class + ); + assertThat(getResponse.getBody().name()).isEqualTo("아디다스"); + assertThat(getResponse.getBody().description()).isEqualTo("변경된 설명"); + } + + @Test + @DisplayName("브랜드 생성 → 삭제 → 조회 실패") + void create_delete_then_getFail() { + // given + createBrand("나이키", "스포츠 브랜드"); + + // when - 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + ADMIN_BASE_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 삭제된 브랜드 조회 시 실패 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + String.class + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 관리자 인증 시나리오") + class AdminAuthE2E { + + @Test + @DisplayName("관리자 인증 없이 브랜드 생성 실패") + void createBrand_unauthorized() { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + ResponseEntity response = restTemplate.postForEntity( + ADMIN_BASE_URL, + request, + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + ResponseEntity response = restTemplate.exchange( + ADMIN_BASE_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java new file mode 100644 index 000000000..ca1bf2f56 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java @@ -0,0 +1,181 @@ +package com.loopers.interfaces.api.brand; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class BrandApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_BASE_URL = "/api-admin/v1/brands"; + private static final String PUBLIC_BASE_URL = "/api/v1/brands"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("브랜드 생성 API") + class CreateBrandApi { + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("중복 이름으로 생성 시 실패") + void createBrand_fail_duplicate() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("관리자 인증 없이 생성 시 실패") + void createBrand_fail_unauthorized() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("브랜드 수정 API") + class UpdateBrandApi { + + @Test + @DisplayName("브랜드 수정 성공") + void updateBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + var updateRequest = new BrandUpdateRequest("아디다스", "변경된 설명"); + + mockMvc.perform(put(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("아디다스")) + .andExpect(jsonPath("$.description").value("변경된 설명")); + } + } + + @Nested + @DisplayName("브랜드 삭제 API") + class DeleteBrandApi { + + @Test + @DisplayName("브랜드 삭제 성공") + void deleteBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + mockMvc.perform(delete(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()); + + // 삭제 확인 + mockMvc.perform(get(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("브랜드 조회 API") + class QueryBrandApi { + + @Test + @DisplayName("브랜드 단건 조회 성공") + void getBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + mockMvc.perform(get(PUBLIC_BASE_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("나이키")) + .andExpect(jsonPath("$.description").value("스포츠 브랜드")); + } + + @Test + @DisplayName("브랜드 목록 조회 성공") + void getBrands_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + createBrand("아디다스", "독일 스포츠 브랜드"); + + mockMvc.perform(get(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 조회 시 실패") + void getBrand_fail_notFound() throws Exception { + mockMvc.perform(get(PUBLIC_BASE_URL + "/999")) + .andExpect(status().isBadRequest()); + } + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java new file mode 100644 index 000000000..55a614978 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -0,0 +1,145 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class LikeApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String LOGIN_ID = "e2euser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("E2E: 좋아요 전체 플로우") + class LikeFlowE2E { + + @Test + @DisplayName("좋아요 → 목록 조회 → 좋아요 취소 → 빈 목록 확인") + void fullLikeFlow() { + // Step 1: 좋아요 + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + ResponseEntity likeResponse = restTemplate.exchange( + "/api/v1/products/1/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders), + Void.class + ); + assertThat(likeResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: likeCount 확인 + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productResponse.getBody().likeCount()).isEqualTo(1); + + // Step 3: 좋아요 목록 조회 + ResponseEntity likesResponse = restTemplate.exchange( + "/api/v1/users/" + LOGIN_ID + "/likes", + HttpMethod.GET, + new HttpEntity<>(authHeaders), + String.class + ); + assertThat(likesResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(likesResponse.getBody()).contains("운동화"); + + // Step 4: 좋아요 취소 + ResponseEntity unlikeResponse = restTemplate.exchange( + "/api/v1/products/1/likes", + HttpMethod.DELETE, + new HttpEntity<>(authHeaders), + Void.class + ); + assertThat(unlikeResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 5: likeCount 0 확인 + ResponseEntity productAfter = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productAfter.getBody().likeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 멱등성 - 중복 좋아요 시 likeCount 1 유지") + void like_idempotent() { + // given + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + + // when - 두 번 좋아요 + restTemplate.exchange("/api/v1/products/1/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders), Void.class); + restTemplate.exchange("/api/v1/products/1/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders), Void.class); + + // then - likeCount는 1 + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", ProductDetailResponse.class); + assertThat(productResponse.getBody().likeCount()).isEqualTo(1); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + restTemplate.postForEntity("/api/v1/users", request, Void.class); + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + restTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java new file mode 100644 index 000000000..54bec699a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java @@ -0,0 +1,174 @@ +package com.loopers.interfaces.api.like; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class LikeApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String LIKE_URL = "/api/v1/products"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + private static final String LOGIN_ID = "testuser1"; + private static final String PASSWORD = "Password1!"; + private static final String MY_LIKES_URL = "/api/v1/users/" + LOGIN_ID + "/likes"; + + @BeforeEach + void setUp() throws Exception { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("좋아요 API") + class LikeApi { + + @Test + @DisplayName("좋아요 성공") + void like_success() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("좋아요 후 상품 likeCount 증가 확인") + void like_then_checkLikeCount() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/products/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(1)); + } + + @Test + @DisplayName("인증 없이 좋아요 시 실패") + void like_fail_unauthorized() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes")) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("좋아요 취소 API") + class UnlikeApi { + + @Test + @DisplayName("좋아요 취소 성공") + void unlike_success() throws Exception { + // 먼저 좋아요 + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 좋아요 취소 + mockMvc.perform(delete(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // likeCount 0 확인 + mockMvc.perform(get("/api/v1/products/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(0)); + } + } + + @Nested + @DisplayName("좋아요 목록 조회 API") + class GetMyLikesApi { + + @Test + @DisplayName("좋아요 목록 조회 성공") + void getMyLikes_success() throws Exception { + // 좋아요 + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 목록 조회 + mockMvc.perform(get(MY_LIKES_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].productId").value(1)) + .andExpect(jsonPath("$[0].productName").value("운동화")); + } + + @Test + @DisplayName("좋아요 없는 경우 빈 목록") + void getMyLikes_empty() throws Exception { + mockMvc.perform(get(MY_LIKES_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post("/api-admin/v1/brands") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java new file mode 100644 index 000000000..b98fcefbb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -0,0 +1,238 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class OrderApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ORDER_URL = "/api/v1/orders"; + private static final String LOGIN_ID = "e2euser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("E2E: 주문 전체 플로우") + class OrderFlowE2E { + + @Test + @DisplayName("주문 생성 → 조회 → 배송지 변경 → 취소 → 재고 복원 확인") + void fullOrderFlow() { + // Step 1: 주문 생성 + var orderRequest = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + authHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity createResponse = restTemplate.exchange( + ORDER_URL, + HttpMethod.POST, + new HttpEntity<>(orderRequest, authHeaders), + Void.class + ); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: 주문 목록 조회 + ResponseEntity listResponse = restTemplate.exchange( + ORDER_URL, + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + String.class + ); + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listResponse.getBody()).contains("PAYMENT_COMPLETED"); + + // Step 3: 주문 상세 조회 + ResponseEntity detailResponse = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(detailResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(detailResponse.getBody().receiverName()).isEqualTo("홍길동"); + assertThat(detailResponse.getBody().address()).isEqualTo("서울시 강남구"); + + // Step 4: 배송지 변경 + var addressRequest = new DeliveryAddressUpdateRequest("부산시 해운대구"); + HttpHeaders addressHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + addressHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity addressResponse = restTemplate.exchange( + ORDER_URL + "/1/delivery-address", + HttpMethod.PUT, + new HttpEntity<>(addressRequest, addressHeaders), + Void.class + ); + assertThat(addressResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 5: 변경된 배송지 확인 + ResponseEntity updatedDetail = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(updatedDetail.getBody().address()).isEqualTo("부산시 해운대구"); + + // Step 6: 주문 취소 + ResponseEntity cancelResponse = restTemplate.exchange( + ORDER_URL + "/1/cancel", + HttpMethod.POST, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + Void.class + ); + assertThat(cancelResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 7: 취소 상태 확인 + ResponseEntity cancelledDetail = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(cancelledDetail.getBody().status()).isEqualTo("CANCELLED"); + + // Step 8: 재고 복원 확인 (원래 100, 2개 주문 → 98, 취소 → 100) + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productResponse.getBody().stock()).isEqualTo(100); + } + } + + @Nested + @DisplayName("E2E: 관리자 주문 관리") + class AdminOrderE2E { + + @Test + @DisplayName("관리자 전체 주문 조회") + void admin_getAllOrders() { + // given - 주문 생성 + createOrder(); + + // when + HttpHeaders adminHeaders = new HttpHeaders(); + adminHeaders.set("X-Loopers-Ldap", "loopers.admin"); + + ResponseEntity response = restTemplate.exchange( + "/api-admin/v1/orders", + HttpMethod.GET, + new HttpEntity<>(adminHeaders), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("PAYMENT_COMPLETED"); + } + + @Test + @DisplayName("관리자 주문 상세 조회") + void admin_getOrderDetail() { + // given + createOrder(); + + // when + HttpHeaders adminHeaders = new HttpHeaders(); + adminHeaders.set("X-Loopers-Ldap", "loopers.admin"); + + ResponseEntity response = restTemplate.exchange( + "/api-admin/v1/orders/1", + HttpMethod.GET, + new HttpEntity<>(adminHeaders), + OrderDetailResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().receiverName()).isEqualTo("홍길동"); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + restTemplate.postForEntity("/api/v1/users", request, Void.class); + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + restTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createOrder() { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + HttpHeaders headers = createAuthHeaders(LOGIN_ID, PASSWORD); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.exchange(ORDER_URL, HttpMethod.POST, + new HttpEntity<>(request, headers), Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java new file mode 100644 index 000000000..3aa07c19f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java @@ -0,0 +1,266 @@ +package com.loopers.interfaces.api.order; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class OrderApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ORDER_URL = "/api/v1/orders"; + private static final String ADMIN_ORDER_URL = "/api-admin/v1/orders"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + private static final String LOGIN_ID = "testuser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() throws Exception { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("주문 생성 API") + class CreateOrderApi { + + @Test + @DisplayName("주문 생성 성공") + void createOrder_success() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + + mockMvc.perform(post(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("인증 없이 주문 생성 시 실패") + void createOrder_fail_unauthorized() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시", "요청", "CARD", LocalDate.now() + ); + + mockMvc.perform(post(ORDER_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("주문 조회 API") + class QueryOrderApi { + + @Test + @DisplayName("내 주문 목록 조회 성공") + void getMyOrders_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].status").value("PAYMENT_COMPLETED")); + } + + @Test + @DisplayName("주문 상세 조회 성공") + void getOrderDetail_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.receiverName").value("홍길동")) + .andExpect(jsonPath("$.status").value("PAYMENT_COMPLETED")) + .andExpect(jsonPath("$.items.length()").value(1)); + } + + @Test + @DisplayName("기간 필터 조회") + void getMyOrders_withDateRange() throws Exception { + createOrder(); + + String startAt = LocalDate.now().minusDays(1).toString(); + String endAt = LocalDate.now().plusDays(1).toString(); + + mockMvc.perform(get(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .param("startAt", startAt) + .param("endAt", endAt)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + } + + @Nested + @DisplayName("주문 취소 API") + class CancelOrderApi { + + @Test + @DisplayName("주문 취소 성공") + void cancelOrder_success() throws Exception { + createOrder(); + + mockMvc.perform(post(ORDER_URL + "/1/cancel") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 취소 상태 확인 + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CANCELLED")); + } + } + + @Nested + @DisplayName("배송지 변경 API") + class UpdateDeliveryAddressApi { + + @Test + @DisplayName("배송지 변경 성공") + void updateDeliveryAddress_success() throws Exception { + createOrder(); + + var request = new DeliveryAddressUpdateRequest("부산시 해운대구"); + + mockMvc.perform(put(ORDER_URL + "/1/delivery-address") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.address").value("부산시 해운대구")); + } + } + + @Nested + @DisplayName("관리자 주문 조회 API") + class AdminOrderApi { + + @Test + @DisplayName("관리자 전체 주문 목록 조회") + void getAllOrders_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ADMIN_ORDER_URL) + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("관리자 주문 상세 조회") + void getOrderDetail_admin() throws Exception { + createOrder(); + + mockMvc.perform(get(ADMIN_ORDER_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.receiverName").value("홍길동")); + } + + @Test + @DisplayName("관리자 인증 없이 조회 시 실패") + void getAllOrders_fail_unauthorized() throws Exception { + mockMvc.perform(get(ADMIN_ORDER_URL)) + .andExpect(status().isUnauthorized()); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post("/api-admin/v1/brands") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createOrder() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + mockMvc.perform(post(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java new file mode 100644 index 000000000..28ce8d3be --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -0,0 +1,180 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class ProductApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_URL = "/api-admin/v1/products"; + private static final String PUBLIC_URL = "/api/v1/products"; + private static final String BRAND_ADMIN_URL = "/api-admin/v1/brands"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 상품 CRUD 시나리오") + class ProductCrudE2E { + + @Test + @DisplayName("상품 생성 → 상세 조회 성공") + void create_then_getDetail() { + // given + createBrand("나이키", "스포츠"); + var request = new ProductCreateRequest(1L, "운동화", 50000, null, 100, "좋은 운동화"); + + // when - 생성 + ResponseEntity createResponse = restTemplate.exchange( + ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_URL + "/1", + ProductDetailResponse.class + ); + + // then + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).isNotNull(); + assertThat(getResponse.getBody().name()).isEqualTo("운동화"); + assertThat(getResponse.getBody().brandName()).isEqualTo("나이키"); + assertThat(getResponse.getBody().price()).isEqualTo(50000); + } + + @Test + @DisplayName("상품 목록 조회 (페이징)") + void getProductList() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + // when + ResponseEntity response = restTemplate.getForEntity( + PUBLIC_URL + "?page=0&size=20", + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("운동화"); + assertThat(response.getBody()).contains("슬리퍼"); + } + + @Test + @DisplayName("상품 생성 → 삭제 → 조회 실패") + void create_delete_then_getFail() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + // when - 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + ADMIN_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 삭제된 상품 조회 실패 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_URL + "/1", + String.class + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 브랜드 삭제 cascade 시나리오") + class BrandDeleteCascadeE2E { + + @Test + @DisplayName("브랜드 삭제 시 하위 상품도 삭제됨") + void deleteBrand_cascadeProducts() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + // when - 브랜드 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + BRAND_ADMIN_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 상품 조회 실패 + ResponseEntity product1Response = restTemplate.getForEntity( + PUBLIC_URL + "/1", String.class); + assertThat(product1Response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + ResponseEntity product2Response = restTemplate.getForEntity( + PUBLIC_URL + "/2", String.class); + assertThat(product2Response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + ResponseEntity response = restTemplate.exchange( + BRAND_ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + ResponseEntity response = restTemplate.exchange( + ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java new file mode 100644 index 000000000..486478c69 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java @@ -0,0 +1,205 @@ +package com.loopers.interfaces.api.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class ProductApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_URL = "/api-admin/v1/products"; + private static final String PUBLIC_URL = "/api/v1/products"; + private static final String BRAND_ADMIN_URL = "/api-admin/v1/brands"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("상품 생성 API") + class CreateProductApi { + + @Test + @DisplayName("상품 생성 성공") + void createProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + + var request = new ProductCreateRequest(1L, "운동화", 50000, null, 100, "좋은 운동화"); + + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 상품 생성시 실패") + void createProduct_fail_brandNotFound() throws Exception { + var request = new ProductCreateRequest(999L, "운동화", 50000, null, 100, "좋은 운동화"); + + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("상품 수정 API") + class UpdateProductApi { + + @Test + @DisplayName("상품 수정 성공") + void updateProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + var updateRequest = new ProductUpdateRequest("슬리퍼", 30000, null, 200, "변경된 설명"); + + mockMvc.perform(put(ADMIN_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("슬리퍼")) + .andExpect(jsonPath("$.price").value(30000)); + } + } + + @Nested + @DisplayName("상품 삭제 API") + class DeleteProductApi { + + @Test + @DisplayName("상품 삭제 성공") + void deleteProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(delete(ADMIN_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()); + + // 삭제 확인 + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("상품 조회 API") + class QueryProductApi { + + @Test + @DisplayName("상품 상세 조회 성공") + void getProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("운동화")) + .andExpect(jsonPath("$.price").value(50000)) + .andExpect(jsonPath("$.brandName").value("나이키")); + } + + @Test + @DisplayName("상품 목록 조회 성공 (페이징)") + void getProducts_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + mockMvc.perform(get(PUBLIC_URL) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.totalElements").value(2)) + .andExpect(jsonPath("$.page").value(0)); + } + + @Test + @DisplayName("브랜드 필터링 조회") + void getProducts_withBrandFilter() throws Exception { + createBrand("나이키", "스포츠"); + createBrand("아디다스", "독일"); + createProduct(1L, "나이키 운동화", 50000, 100); + createProduct(2L, "아디다스 운동화", 60000, 50); + + mockMvc.perform(get(PUBLIC_URL) + .param("brandId", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].brandName").value("나이키")); + } + + @Test + @DisplayName("관리자 상품 목록 조회") + void getProducts_admin() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(get(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)); + } + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post(BRAND_ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 45201ede5..42accb243 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserInfoResponse; -import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserInfoResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; @@ -51,7 +51,7 @@ void register_then_getMyInfo() { // when - 회원가입 ResponseEntity registerResponse = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, registerRequest, Void.class ); @@ -84,11 +84,11 @@ void register_duplicateId_fail() { var request = createRegisterRequest(loginId, "Password1!", "홍길동"); // 첫 번째 가입 - restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + restTemplate.postForEntity(BASE_URL, request, Void.class); // when - 동일 ID로 재가입 ResponseEntity response = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, request, Void.class ); @@ -230,7 +230,7 @@ void fullUserFlow() { var registerRequest = createRegisterRequest(loginId, password, "김철수"); ResponseEntity registerResponse = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, registerRequest, Void.class ); @@ -292,6 +292,6 @@ private HttpHeaders createAuthHeaders(String loginId, String password) { private void registerUser(String loginId, String password, String name) { var request = createRegisterRequest(loginId, password, name); - restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); - } + ResponseEntity response = restTemplate.postForEntity(BASE_URL, request, Void.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java index 87994f717..fdc122bb3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; @@ -58,7 +58,7 @@ void register_success() throws Exception { "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); @@ -76,13 +76,13 @@ void register_fail_duplicateId() throws Exception { ); // 첫 번째 가입 - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); // 동일 ID로 재가입 시도 - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -99,7 +99,7 @@ void register_fail_missingFields() throws Exception { "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -116,7 +116,7 @@ void register_fail_invalidEmail() throws Exception { "invalid-email" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -267,7 +267,7 @@ private void registerUser(String loginId, String password, String name) throws E "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java index 6125054a3..9f6df066a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.UserQueryUseCase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/claudedocs/process.md b/claudedocs/process.md new file mode 100644 index 000000000..e69de29bb diff --git a/claudedocs/week3.md b/claudedocs/week3.md new file mode 100644 index 000000000..e69de29bb diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0