diff --git a/.docs/Reason.md b/.docs/Reason.md deleted file mode 100644 index 6558ceabd..000000000 --- a/.docs/Reason.md +++ /dev/null @@ -1,270 +0,0 @@ -# 설계 결정 근거 (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 2009d080d..8bc92d09d 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -48,9 +48,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Guest | `POST` | `/api/v1/users` | **회원가입** | ID 중복 체크 필수 | -| User | `GET` | `/api/v1/users/me` | **내 정보 조회** | 이름 마스킹 처리 | -| User | `PUT` | `/api/v1/users/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | +| Guest | `POST` | `/users/register` | **회원가입** | ID 중복 체크 필수 | +| User | `GET` | `/users/me` | **내 정보 조회** | 이름 마스킹 처리 | +| User | `PUT` | `/users/me/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | #### 상세 요구사항 * **회원가입 입력값**: ID, PW, 이름, 생년월일, 이메일 @@ -67,9 +67,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Any | `GET` | `/api/v1/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | -| Any | `GET` | `/api/v1/products` | **상품 목록** | 필터, 정렬, 페이징 | -| Any | `GET` | `/api/v1/products/{productId}` | **상품 상세** | | +| Any | `GET` | `/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | +| Any | `GET` | `/products` | **상품 목록** | 필터, 정렬, 페이징 | +| Any | `GET` | `/products/{productId}` | **상품 상세** | | #### 상세 요구사항 * **목록 조회 쿼리 파라미터**: @@ -83,9 +83,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/api/v1/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | -| User | `DELETE` | `/api/v1/products/{id}/likes` | **좋아요 취소** | | -| User | `GET` | `/api/v1/users/{userId}/likes` | **좋아요 목록** | 필터링 지원 | +| User | `POST` | `/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | +| User | `DELETE` | `/products/{id}/likes` | **좋아요 취소** | | +| User | `GET` | `/users/me/likes` | **좋아요 목록** | 필터링 지원 | #### 상세 요구사항 * **제약**: 유저당 1개의 상품에 1번만 좋아요 가능. @@ -99,9 +99,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/api/v1/orders` | **주문 요청** | 트랜잭션 처리 필수 | -| User | `GET` | `/api/v1/orders` | **내 주문 목록** | `startAt`, `endAt` 기간 필터 | -| User | `GET` | `/api/v1/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | +| User | `POST` | `/orders` | **주문 요청** | 트랜잭션 처리 필수 | +| User | `GET` | `/orders/me` | **내 주문 목록** | 기간 조회 | +| User | `GET` | `/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | #### 상세 요구사항 1. **주문 요청**: @@ -122,17 +122,13 @@ | 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}` | 주문 상세 조회 | | --- @@ -162,6 +158,7 @@ | 코드 | 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 60f901868..a7b525697 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 (loginId, password, name, birthday, email) + User->>API: POST /api/v1/users/register (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/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) + User->>API: PUT /api/v1/users/me/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 b6d38a7c3..a04288679 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -131,6 +131,7 @@ classDiagram -UserRepository userRepository -PasswordEncoder passwordEncoder +authenticate(UserId, String) void + -findUser(UserId) User } %% --- Interceptor → UseCase --- @@ -182,7 +183,7 @@ classDiagram - **인증 관심사 분리**: `AuthenticationInterceptor`가 `/api/v1/users/me/**` 경로의 인증을 전담한다. Controller는 `AuthenticationUseCase`를 더 이상 알지 못하며, `HttpServletRequest`의 `authenticatedUserId` 속성에서 인증된 사용자를 꺼내 쓴다. - **Service 분리**: `UserService`는 Register, Query, PasswordUpdate만 구현하고, `AuthenticationService`가 인증만 전담한다. 향후 도메인(주문, 좋아요 등)이 추가되어도 각 도메인별 Service가 독립적으로 존재하는 패턴의 기반이 된다. -- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `POST /api/v1/users` (회원가입)는 인증 없이 접근 가능하다. +- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `/api/v1/users/register`는 인증 없이 접근 가능하다. ### 설계 의도 @@ -624,6 +625,7 @@ classDiagram <> +handleCoreException(CoreException) ResponseEntity +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity + +handleValidationException(MethodArgumentNotValidException) ResponseEntity +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity +handleException(Exception) ResponseEntity } @@ -762,7 +764,6 @@ classDiagram +updateBrand(Long, BrandUpdateRequest) ResponseEntity +deleteBrand(Long) ResponseEntity +getBrands() ResponseEntity - +getBrand(Long) ResponseEntity } class BrandController { <> @@ -887,12 +888,9 @@ 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 { <> @@ -936,7 +934,6 @@ classDiagram ProductAdminController ..> CreateProductUseCase : uses ProductAdminController ..> UpdateProductUseCase : uses ProductAdminController ..> DeleteProductUseCase : uses - ProductAdminController ..> ProductQueryUseCase : uses ProductController ..> ProductQueryUseCase : uses ProductService ..|> CreateProductUseCase : implements @@ -1152,7 +1149,6 @@ classDiagram <> -OrderQueryUseCase orderQueryUseCase +getAllOrders(OrderSearchCondition) ResponseEntity - +getOrder(Long) ResponseEntity } class CreateOrderUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java new file mode 100644 index 000000000..3d274cd6f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application; + +import com.loopers.domain.model.UserId; + +public interface AuthenticationUseCase { + + void authenticate(UserId userId, String rawPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java new file mode 100644 index 000000000..b4fbf5ee6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application; + +import com.loopers.domain.model.UserId; + +public interface PasswordUpdateUseCase { + + void updatePassword(UserId userId, String currentRawPassword, String newRawPassword); +} 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 new file mode 100644 index 000000000..2a9a803e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java @@ -0,0 +1,8 @@ +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/UserQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java new file mode 100644 index 000000000..75eb713e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java @@ -0,0 +1,17 @@ +package com.loopers.application; + +import com.loopers.domain.model.UserId; + +import java.time.LocalDate; + +public interface UserQueryUseCase { + + UserInfoResponse getUserInfo(UserId userId); + + record UserInfoResponse( + String loginId, + String maskedName, + 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 deleted file mode 100644 index 0d7cd676c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index b5bd568a3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 99c63e548..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 47f67911e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index cc0e7bc09..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index ebd3872b1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5a0eb55cf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 0ba0d8497..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 0250d851b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 1e7920159..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 967a0ce1c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 3f9a5558f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index ebec687cf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index b95381919..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index a7115aef0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 18f3e2e0a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 3fc997653..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 319f13d6a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index fadc9e0c7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 0613271be..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index a01ed6734..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 9265bd4f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 703e2626d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index e8faae10b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index bc6c32b07..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index fd32c1c03..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 21dd42b0c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -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/service/AuthenticationService.java new file mode 100644 index 000000000..f2f6fa6f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java @@ -0,0 +1,36 @@ +package com.loopers.application.service; + +import com.loopers.application.AuthenticationUseCase; +import com.loopers.domain.model.User; +import com.loopers.domain.model.UserId; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class AuthenticationService implements AuthenticationUseCase { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public AuthenticationService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public void authenticate(UserId userId, String rawPassword) { + User user = findUser(userId); + + if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + } + + private User findUser(UserId userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + } +} 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/service/UserService.java new file mode 100644 index 000000000..46a014918 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java @@ -0,0 +1,97 @@ +package com.loopers.application.service; + +import com.loopers.application.PasswordUpdateUseCase; +import com.loopers.application.RegisterUseCase; +import com.loopers.application.UserQueryUseCase; +import com.loopers.domain.model.*; +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 +@Transactional(readOnly = true) +public class UserService implements RegisterUseCase, PasswordUpdateUseCase, UserQueryUseCase { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @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); + String encodedPassword = passwordEncoder.encrypt(password.getValue()); + + try { + User user = User.register( + userId, userName, encodedPassword, birth, + userEmail, WrongPasswordCount.init(), LocalDateTime.now() + ); + userRepository.save(user); + } catch (DataIntegrityViolationException ex) { + throw new IllegalArgumentException("이미 사용중인 ID 입니다.", ex); + } + } + + @Override + @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); + userRepository.save(updatedUser); + } + + @Override + public UserInfoResponse getUserInfo(UserId userId) { + User user = findUser(userId); + + return new UserInfoResponse( + user.getUserId().getValue(), + maskName(user.getUserName().getValue()), + user.getBirth().getValue(), + user.getEmail().getValue() + ); + } + + 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/application/user/RegisterUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java deleted file mode 100644 index 46d645572..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java +++ /dev/null @@ -1,13 +0,0 @@ -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/domain/model/Birthday.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java new file mode 100644 index 000000000..5ff4694b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java @@ -0,0 +1,27 @@ +package com.loopers.domain.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.time.LocalDate; + +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) // 생성자를 private으로 제한 +public class Birthday { + + LocalDate value; + + public static Birthday of(LocalDate value) { + if(value == null) { + throw new IllegalArgumentException("생년월일은 필수 입력값입니다."); + } + if(value.isAfter(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 미래 날짜일 수 없습니다."); + } + if(value.isBefore(LocalDate.of(1900, 1,1))) { + throw new IllegalArgumentException("생년월일은 1900년 이후여야 합니다."); + } + return new Birthday(value); + } +} 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/Email.java new file mode 100644 index 000000000..9b615d564 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java @@ -0,0 +1,30 @@ +package com.loopers.domain.model; + +import lombok.Data; + +import java.util.regex.Pattern; + +@Data +public class Email { + + private static final Pattern PATTERN = Pattern.compile( + "^[a-zA-Z0-9]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ); + + private final String value; + + private Email(String value) { + this.value = value; + } + + public static Email of(String value) { + if(value == null || value.isBlank()) { + throw new IllegalArgumentException("이메일은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if(!PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException("올바른 이메일 형식이 아닙니다"); + } + return new Email(trimmed); + } +} 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/Password.java new file mode 100644 index 000000000..f39902cdc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java @@ -0,0 +1,60 @@ +package com.loopers.domain.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; + +@Getter +@EqualsAndHashCode +@ToString +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"); + private static final DateTimeFormatter FMT_YYMMDD = DateTimeFormatter.ofPattern("yyMMdd"); + private static final DateTimeFormatter FMT_MMDD = DateTimeFormatter.ofPattern("MMdd"); + private static final DateTimeFormatter FMT_YYYY_MM_DD = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter FMT_YY_MM_DD = DateTimeFormatter.ofPattern("yy-MM-dd"); + private static final DateTimeFormatter FMT_MM_DD = DateTimeFormatter.ofPattern("MM-dd"); + + private final String value; + + private Password(String value) {this.value = value;} + + public static Password of(String rawPassword, LocalDate birthday) { + validate(rawPassword, birthday); + return new Password(rawPassword); + } + private static void validate(String rawPassword, LocalDate birthday) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new IllegalArgumentException("비밀번호는 필수 입력값입니다."); + } + + if (!ALLOWED_CHARS.matcher(rawPassword).matches()) { + throw new IllegalArgumentException("비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + if (birthday != null && containsBirthday(rawPassword, birthday)) { + throw new IllegalArgumentException("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } + + static boolean containsBirthday(String rawPassword, LocalDate birthday) { + //yyyyMMdd, yyMMdd, MMdd 같은 포멧은 다 제외 하는걸로 + List patterns = List.of( + birthday.format(FMT_YYYYMMDD), + birthday.format(FMT_YYMMDD), + birthday.format(FMT_MMDD), + birthday.format(FMT_YYYY_MM_DD), + birthday.format(FMT_YY_MM_DD), + birthday.format(FMT_MM_DD) + ); + + return patterns.stream().anyMatch(rawPassword::contains); + + } +} 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 new file mode 100644 index 000000000..f321978e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java @@ -0,0 +1,51 @@ +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/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java new file mode 100644 index 000000000..541f29d95 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java @@ -0,0 +1,27 @@ +package com.loopers.domain.model; + +import lombok.Data; + +import java.util.regex.Pattern; + +@Data +public class UserId { + + private static final Pattern PATTERN = Pattern.compile("^[a-z0-9]{4,10}$"); + + private final String value; + + private UserId(String value) {this.value = value;} + + public static UserId of(String value) { + if(value == null || value.isBlank()) { + throw new IllegalArgumentException("로그인 ID는 필수 입력값입니다."); + } + String trimmed = value.trim(); + if(!PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException( + "로그인 ID는 4~10자의 영문 소문자, 숫자만 가능합니다."); + } + return new UserId(trimmed); + } +} 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/UserName.java new file mode 100644 index 000000000..cd5e10ff1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java @@ -0,0 +1,25 @@ +package com.loopers.domain.model; + +import lombok.Data; + +import java.util.regex.Pattern; + +@Data +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;} + + public static UserName of(String value) { + if(value == null || value.isEmpty()) { + throw new IllegalArgumentException("이름은 필수 값입니다."); + } + String trimmed = value.trim(); + if(!PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException("이름은 2~20자의 한글 또는 영문만 가능합니다."); + } + return new UserName(trimmed); + } +} 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 new file mode 100644 index 000000000..9c2e182fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java @@ -0,0 +1,39 @@ +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 deleted file mode 100644 index 8f20337ee..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 58ec404d3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index bb2ca8d71..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 8642c0650..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 010fd01fc..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index c8a9efec6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index b4c71620b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8fb3c7833..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index ee94dfb62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 0b4f72443..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index a02f89f4e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 506866b22..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 718451fe6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 65cc76666..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index d964c0d96..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index df7f37294..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 33188c619..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 806715589..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 8ad2843fa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 352d71de7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 9cd84223e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 409e832b9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 2ba2237d2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index b07c31cee..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e80d75203..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 3848e6493..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index a7f713a02..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index cbd2c7862..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java +++ /dev/null @@ -1,43 +0,0 @@ -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/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java deleted file mode 100644 index e6304d656..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 509ce187f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java +++ /dev/null @@ -1,15 +0,0 @@ -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/repository/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java deleted file mode 100644 index af78a4129..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 3430e99fb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index d50f11b66..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index a157365c3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -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 90e580d89..f2b4eae02 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.User; -import com.loopers.domain.model.user.UserId; +import com.loopers.domain.model.User; +import com.loopers.domain.model.UserId; 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/UserRepositoryImpl.java new file mode 100644 index 000000000..226376350 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.model.*; +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; + +@Repository +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + public UserRepositoryImpl( UserJpaRepository userJpaRepository) {this.userJpaRepository = userJpaRepository;} + + @Override + public User save(User user) { + UserJpaEntity entity = toEntity(user); + UserJpaEntity saved = userJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(UserId userId) { + return userJpaRepository.findByUserId(userId.getValue()) + .map(this::toDomain); + } + + @Override + public boolean existsById(UserId userId) { + return userJpaRepository.existsByUserId(userId.getValue()); + } + + private UserJpaEntity toEntity(User user) { + return new UserJpaEntity( + user.getId(), + user.getUserId(), + user.getEncodedPassword(), + user.getUserName(), + user.getBirth(), + user.getEmail(), + user.getCreatedAt() + ); + } + + private User toDomain(UserJpaEntity entity) { + return User.reconstitute( + entity.getId(), + UserId.of(entity.getUserId()), + UserName.of(entity.getUsername()), + entity.getEncodedPassword(), + Birthday.of(entity.getBirthday()), + Email.of(entity.getEmail()), + WrongPasswordCount.init(), + entity.getCreatedAt() + ); + } + +} 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 deleted file mode 100644 index 3b6d7c6ad..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 623f76575..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index bf0daff58..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index aaf9f8353..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java +++ /dev/null @@ -1,22 +0,0 @@ -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/entity/UserJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java new file mode 100644 index 000000000..faccc97ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.entity; + +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 jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "users") +public class UserJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String userId; + + @Column(nullable = false) + private String encodedPassword; + + @Column(nullable = false, length = 20) + private String username; + + @Column(nullable = false) + private LocalDate birthday; + + @Column(nullable = false) + private String email; + + @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) { + this.id = id; + this.userId = userId.getValue(); + this.encodedPassword = encodedPassword; + this.username = userName.getValue(); + this.birthday = birth.getValue(); + this.email = email.getValue(); + this.createdAt = createdAt; + } +} 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 deleted file mode 100644 index bf999609c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index f33202abc..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index b4c29b8b4..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index d616aae31..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 7ade164a6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 38ca87f9b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0707d0cf0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 082c7a2b0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index fe3c0aef6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 0bd86dbd3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index d905fb1e9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ /dev/null @@ -1,115 +0,0 @@ -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/repository/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java new file mode 100644 index 000000000..6a49ba3e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.repository; + +import com.loopers.infrastructure.entity.UserJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(String userId); + boolean existsByUserId(String userId); +} 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 new file mode 100644 index 000000000..312fc8d70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java @@ -0,0 +1,67 @@ +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 deleted file mode 100644 index 5ecdd3ee5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 73c4cc329..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index c21a5fdfe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index d47aa68f7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 9c26945b1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index ecb3558e4..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -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 2893688c4..773e0928f 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,6 +1,5 @@ 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; @@ -10,23 +9,14 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthenticationInterceptor authenticationInterceptor; - private final AdminAuthenticationInterceptor adminAuthenticationInterceptor; - public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor, - AdminAuthenticationInterceptor adminAuthenticationInterceptor) { + public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor) { 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/*/likes") - .addPathPatterns("/api/v1/products/*/likes") - .addPathPatterns("/api/v1/orders", "/api/v1/orders/**"); - - registry.addInterceptor(adminAuthenticationInterceptor) - .addPathPatterns("/api-admin/v1/**"); + .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**"); } } 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/dto/PasswordUpdateRequest.java new file mode 100644 index 000000000..24a38ea36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.interfaces.api.dto; + +public record PasswordUpdateRequest( + String currentPassword, + String newPassword +) {} 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/dto/UserInfoResponse.java new file mode 100644 index 000000000..e06b1acb1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.UserQueryUseCase; + +import java.time.format.DateTimeFormatter; + +public record UserInfoResponse( + String loginId, + String name, + String birthday, + String email +) { + private static final DateTimeFormatter BIRTHDAY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public static UserInfoResponse from(UserQueryUseCase.UserInfoResponse userInfo) { + return new UserInfoResponse( + userInfo.loginId(), + userInfo.maskedName(), + userInfo.birthday().format(BIRTHDAY_FORMATTER), + userInfo.email() + ); + } +} 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 new file mode 100644 index 000000000..86fbbed7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java @@ -0,0 +1,15 @@ +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 deleted file mode 100644 index 9f424cf5b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java +++ /dev/null @@ -1,35 +0,0 @@ -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 aa79f6a48..328f8e7d9 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.user.AuthenticationUseCase; -import com.loopers.domain.model.user.UserId; +import com.loopers.application.AuthenticationUseCase; +import com.loopers.domain.model.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 deleted file mode 100644 index c6c69780a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 45311e2f8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 74afcf1eb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 5cb4c5fe9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 1b030252f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 3b4396f40..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 9a3ee5a8f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6887d5048..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 4642f5806..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 28697636d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 52ce08927..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 2a9d3e6ac..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 7bc99bc8b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index da063c2e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index fdb3ab9a8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java +++ /dev/null @@ -1,85 +0,0 @@ -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/user/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java deleted file mode 100644 index f02420f48..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -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 54b1de7b7..105a897c7 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,19 +2,18 @@ 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( @@ -33,6 +32,21 @@ 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 deleted file mode 100644 index 7f6a04dff..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 57b407308..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index d30064ec4..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index ac4e91397..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index b5f756b48..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index e3409979a..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ /dev/null @@ -1,234 +0,0 @@ -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 deleted file mode 100644 index ab0fa30cb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index e6d02e4fb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ /dev/null @@ -1,158 +0,0 @@ -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/service/AuthenticationServiceTest.java new file mode 100644 index 000000000..39af9240a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java @@ -0,0 +1,97 @@ +package com.loopers.application.service; + +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class AuthenticationServiceTest { + + private UserRepository userRepository; + private PasswordEncoder passwordEncoder; + private AuthenticationService service; + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + passwordEncoder = mock(PasswordEncoder.class); + service = new AuthenticationService(userRepository, passwordEncoder); + } + + @Test + @DisplayName("인증 성공") + void authenticate_success() { + // given + UserId userId = UserId.of("test1234"); + String rawPassword = "Password1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.authenticate(userId, rawPassword)); + + verify(userRepository).findById(userId); + verify(passwordEncoder).matches(rawPassword, encodedPassword); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authenticate_fail_userNotFound() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, "password")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("비밀번호 불일치 인증 실패") + void authenticate_fail_passwordMismatch() { + // given + UserId userId = UserId.of("test1234"); + String wrongPassword = "WrongPassword1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + } + + private User createUser(UserId userId, String encodedPassword) { + return User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + 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/service/UserServiceTest.java new file mode 100644 index 000000000..6e62421b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java @@ -0,0 +1,251 @@ +package com.loopers.application.service; + +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +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.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class UserServiceTest { + + private UserRepository userRepository; + private PasswordEncoder passwordEncoder; + private UserService service; + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + passwordEncoder = mock(PasswordEncoder.class); + service = new UserService(userRepository, passwordEncoder); + } + + @Nested + @DisplayName("회원가입") + class Register { + + @Test + @DisplayName("회원가입 성공") + void register_success() { + // given + String loginId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.register(loginId, name, rawPassword, BIRTHDAY, email)); + + verify(passwordEncoder).encrypt(rawPassword); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("중복된 ID로 가입시 예외") + void register_fail_duplicated_id() { + // given + String duplicatedId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + doThrow(new DataIntegrityViolationException("Duplicate entry")) + .when(userRepository).save(any(User.class)); + + // when & then + assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, BIRTHDAY, email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용중인 ID"); + } + } + + @Nested + @DisplayName("비밀번호 변경") + class PasswordUpdate { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + when(passwordEncoder.matches(newRawPassword, "encoded_current")).thenReturn(false); + when(passwordEncoder.encrypt(newRawPassword)).thenReturn("encoded_new"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)); + + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("현재 비밀번호 불일치시 예외") + void updatePassword_fail_wrong_current() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String wrongRawPassword = "WrongPw1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongRawPassword, "encoded_current")).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, wrongRawPassword, newRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면 예외") + void updatePassword_fail_same_password() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String sameRawPassword = "Current1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, sameRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("존재하지 않는 사용자면 예외") + void updatePassword_fail_user_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, "Current1!", "NewPass1!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("내 정보 조회") + class UserQuery { + + @Test + @DisplayName("내 정보 조회 성공") + void getUserInfo_success() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.loginId()).isEqualTo("test1234"); + assertThat(result.maskedName()).isEqualTo("홍길*"); + assertThat(result.birthday()).isEqualTo(BIRTHDAY); + assertThat(result.email()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("이름 마스킹 - 2자") + void getUserInfo_maskedName_2chars() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.maskedName()).isEqualTo("홍*"); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회시 예외") + void getUserInfo_fail_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getUserInfo(userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("이름은 최소 2자 이상이어야 한다") + void userName_fail_lessThan2chars() { + assertThatThrownBy(() -> UserName.of("홍")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2~20자"); + } + } + + private User createUser(UserId userId, String encodedPassword) { + return User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + } +} 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/BirthdayTest.java new file mode 100644 index 000000000..a2e0ff907 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java @@ -0,0 +1,82 @@ +package com.loopers.domain.model; + +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 BirthdayTest { + + @Test + @DisplayName("유효한 생년월일 생성 성공") + void create_success() { + // given + LocalDate date = LocalDate.of(1990, 5, 15); + + // when + Birthday birthday = Birthday.of(date); + + // then + assertThat(birthday.getValue()).isEqualTo(date); + } + + @Test + @DisplayName("생년월일 null이면 예외") + void create_fail_null() { + // given + LocalDate date = null; + + // when and then + assertThatThrownBy(() -> Birthday.of(date)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("생년월일은 필수 입력값입니다."); + } + + @Test + @DisplayName("생년월일 미래 날짜면 예외") + void create_fail_future() { + // given + LocalDate future = LocalDate.now().plusDays(1); + + // when and then + assertThatThrownBy(() -> Birthday.of(future)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("미래 날짜"); + } + + @Test + @DisplayName("생년월일 1900년 이전이면 예외") + void create_fail_before_1900() { + // given + LocalDate old = LocalDate.of(1899, 12, 31); + + // when and then + assertThatThrownBy(() -> Birthday.of(old)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1900년"); + } + + @Test + @DisplayName("생년월일 오늘 날짜 가능") + void create_success_today() { + // given + LocalDate today = LocalDate.now(); + + // when + Birthday birthday = Birthday.of(today); + + // then + assertThat(birthday.getValue()).isEqualTo(today); + } + + @Test + @DisplayName("생년월일 1900-01-01 성공 (최소 경계)") + void create_success_min_boundary() { + LocalDate minDate = LocalDate.of(1900, 1, 1); + Birthday birthday = Birthday.of(minDate); + assertThat(birthday.getValue()).isEqualTo(minDate); + } +} 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/EmailTest.java new file mode 100644 index 000000000..3393a5c1d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java @@ -0,0 +1,94 @@ +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 EmailTest { + + @Test + @DisplayName("유효한 이메일 생성 성공") + void create_success() { + // given + String value = "test@example.com"; + + // when + Email email = Email.of(value); + + // then + assertThat(email.getValue()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("이메일 null이면 예외") + void create_fail_null() { + // given + String value = null; + + // when and then + assertThatThrownBy(() -> Email.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이메일은 필수 입력값입니다."); + } + + @Test + @DisplayName("이메일 공백이면 예외") + void create_fail_blank() { + // given + String value = " "; + + // when and then + assertThatThrownBy(() -> Email.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이메일은 필수 입력값입니다."); + } + + @Test + @DisplayName("이메일 형식 오류 - @ 없음") + void create_fail_no_at() { + // given + String value = "testexample.com"; + + // when and then + assertThatThrownBy(() -> Email.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이메일 형식"); + } + + @Test + @DisplayName("이메일 형식 오류 - 도메인 없음") + void create_fail_no_domain() { + // given + String value = "test@"; + + // when and then + assertThatThrownBy(() -> Email.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이메일 형식"); + } + + @Test + @DisplayName("이메일 공백 trim 처리") + void create_success_with_trim() { + // given + String value = " test@example.com "; + + // when + Email email = Email.of(value); + + // then + assertThat(email.getValue()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("동일한 이메일은 equals/hashCode 동등") + void equals_hashCode_consistency() { + Email email1 = Email.of("test@example.com"); + Email email2 = Email.of("test@example.com"); + + assertThat(email1).isEqualTo(email2); + assertThat(email1.hashCode()).isEqualTo(email2.hashCode()); + } +} 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/PasswordTest.java new file mode 100644 index 000000000..d85452e5f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.model; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +public class PasswordTest { + + private static final LocalDate BIRTHDAY = LocalDate.of(1993, 1, 1); + + @Test + void 유효한_비밀번호_생성_성공(){ + Password password = Password.of("Valid1234!!!", BIRTHDAY); + + assertThat(password.getValue()).isEqualTo("Valid1234!!!"); + } + + @Test + void 비밀번호_null_이면_비밀번호_필수_입력값(){ + assertThatThrownBy(() -> Password.of(null, BIRTHDAY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비밀번호는 필수 입력값입니다."); + } + + @Test + void 비밀번호_8자리_미만이면_예외(){ + assertThatThrownBy(() -> Password.of("Va234!", BIRTHDAY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + + @Test + void 비밀번호_16자리_초과면_예외(){ + assertThatThrownBy(() -> Password.of("sdfasdfacdfsdfsdver!", BIRTHDAY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + + @Test + void 생년월일_yyyyMMdd_포함시_예외(){ + assertThatThrownBy(() -> Password.of("19930101sisd!!!", BIRTHDAY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("생년월일"); + } + @Test + void 생년월일_yyMMdd_포함시_예외(){ + assertThatThrownBy(() -> Password.of("930101sisd!!!", BIRTHDAY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("생년월일"); + } +} 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/UserIdTest.java new file mode 100644 index 000000000..938ec0bfa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java @@ -0,0 +1,114 @@ +package com.loopers.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + + +class UserIdTest { + + + + @Test + @DisplayName("유효한 로그인 ID 생성 성공") + void create_success() { + //given + String value = "testid1234"; + + //when + UserId userId = UserId.of(value); + + //then + assertThat(userId.getValue()).isEqualTo("testid1234"); + } + + @Test + @DisplayName("로그인 ID null이면 예외") + void create_null() { + //given + String value = null; + + //when and then + assertThatThrownBy(() -> UserId.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("로그인 ID는 필수 입력값입니다."); + + } + + @Test + @DisplayName("로그인 ID 4자 미만이면 예외") + void create_fail_too_short() { + // given + String value = "abc"; + + // when and then + assertThatThrownBy(() -> UserId.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("4~10자"); + } + + @Test + @DisplayName("로그인 ID 10자 초과면 예외") + void create_fail_too_long() { + // given + String value = "abcdefghijk"; + + // when & then + assertThatThrownBy(() -> UserId.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("4~10자"); + } + + @Test + @DisplayName("로그인 ID 특수문자 포함시 예외") + void create_fail_special_char() { + // given + String value = "test@123"; + + // when and then + assertThatThrownBy(() -> UserId.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("영문"); + } + + @Test + @DisplayName("로그인 ID 공백 trim 처리") + void create_success_with_trim() { + // given + String value = " test1234 "; + + // when + UserId userId = UserId.of(value); + + // then + assertThat(userId.getValue()).isEqualTo("test1234"); + } + + @Test + @DisplayName("로그인 ID 4자 성공 (최소 경계)") + void create_success_min_length() { + UserId userId = UserId.of("abcd"); + assertThat(userId.getValue()).isEqualTo("abcd"); + } + + @Test + @DisplayName("로그인 ID 10자 성공 (최대 경계)") + void create_success_max_length() { + UserId userId = UserId.of("abcdefghij"); + assertThat(userId.getValue()).isEqualTo("abcdefghij"); + } + + @Test + @DisplayName("로그인 ID 대문자 포함시 예외") + void create_fail_uppercase() { + //give + String value = "TEST1234"; + //when and then + assertThatThrownBy(() -> UserId.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("영문 소문자"); + } + +} 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/UserNameTest.java new file mode 100644 index 000000000..2a8c28ee7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java @@ -0,0 +1,85 @@ +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 UserNameTest { + @Test + @DisplayName("유효한 이름 생성 성공 - 한글") + void create_success_korean() { + // given + String value = "홍길동"; + + // when + UserName userName = UserName.of(value); + + // then + assertThat(userName.getValue()).isEqualTo("홍길동"); + } + + @Test + @DisplayName("유효한 이름 생성 성공 - 영문") + void create_success_english() { + // given + String value = "John"; + + // when + UserName userName = UserName.of(value); + + // then + assertThat(userName.getValue()).isEqualTo("John"); + } + + @Test + @DisplayName("이름 null이면 예외") + void create_fail_null() { + // given + String value = null; + + // when & then + assertThatThrownBy(() -> UserName.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이름은 필수 값입니다."); + } + + @Test + @DisplayName("이름 2자 미만이면 예외") + void create_fail_too_short() { + // given + String value = "홍"; + + // when & then + assertThatThrownBy(() -> UserName.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2~20자"); + } + + @Test + @DisplayName("이름 20자 초과면 예외") + void create_fail_too_long() { + // given + String value = "가나다라마바사아자차카타파하가나다라마바사"; + + // when & then + assertThatThrownBy(() -> UserName.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2~20자"); + } + + @Test + @DisplayName("이름 특수문자 포함시 예외") + void create_fail_special_char() { + // given + String value = "홍길동!"; + + // when & then + assertThatThrownBy(() -> UserName.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("한글 또는 영문"); + } + +} 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 new file mode 100644 index 000000000..88c2bfe5e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java @@ -0,0 +1,97 @@ +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 deleted file mode 100644 index f8341215f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 02d38dc43..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 6f91d2d96..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 921f9fbec..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index fb58c87c8..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index d20e6322f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 3f7e971fe..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 3ff6d9185..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java +++ /dev/null @@ -1,191 +0,0 @@ -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 deleted file mode 100644 index 55c109a12..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index f541ccc75..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a16879818..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 6ec26de2f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 12612e898..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java +++ /dev/null @@ -1,74 +0,0 @@ -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/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java deleted file mode 100644 index eeb78c984..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java +++ /dev/null @@ -1,73 +0,0 @@ -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/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java new file mode 100644 index 000000000..45201ede5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -0,0 +1,297 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.dto.UserInfoResponse; +import com.loopers.interfaces.api.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 UserApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 회원가입 시나리오") + class RegisterE2E { + + @Test + @DisplayName("회원가입 → 내 정보 조회 성공") + void register_then_getMyInfo() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "홍길동"); + + // when - 회원가입 + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + + // then - 회원가입 성공 + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + + // then - 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getInfoResponse.getBody()).isNotNull(); + assertThat(getInfoResponse.getBody().loginId()).isEqualTo(loginId); + assertThat(getInfoResponse.getBody().name()).isEqualTo("홍길*"); + assertThat(getInfoResponse.getBody().birthday()).isEqualTo("19900515"); + } + + @Test + @DisplayName("중복 ID 가입 시도 실패") + void register_duplicateId_fail() { + // given + String loginId = "e2euser1"; + var request = createRegisterRequest(loginId, "Password1!", "홍길동"); + + // 첫 번째 가입 + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + + // when - 동일 ID로 재가입 + ResponseEntity response = restTemplate.postForEntity( + BASE_URL + "/register", + request, + Void.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 인증 시나리오") + class AuthenticationE2E { + + @Test + @DisplayName("잘못된 비밀번호로 인증 실패") + void authentication_wrongPassword_fail() { + // given + String loginId = "e2euser1"; + registerUser(loginId, "Password1!", "홍길동"); + + // when - 잘못된 비밀번호로 조회 + HttpHeaders headers = createAuthHeaders(loginId, "WrongPassword1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authentication_userNotFound_fail() { + // when + HttpHeaders headers = createAuthHeaders("notexist", "Password1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + @DisplayName("E2E: 비밀번호 변경 시나리오") + class PasswordChangeE2E { + + @Test + @DisplayName("비밀번호 변경 → 새 비밀번호로 로그인 성공") + void changePassword_then_loginWithNewPassword() { + // given + String loginId = "e2euser1"; + String oldPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, oldPassword, "홍길동"); + + // when - 비밀번호 변경 + HttpHeaders headers = createAuthHeaders(loginId, oldPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(oldPassword, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + + // then - 변경 성공 + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + + // then - 새 비밀번호로 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 이전 비밀번호로 조회 시도 + HttpHeaders oldHeaders = createAuthHeaders(loginId, oldPassword); + ResponseEntity oldPasswordResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(oldHeaders), + String.class + ); + + // then - 이전 비밀번호로는 실패 + assertThat(oldPasswordResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void changePassword_samePassword_fail() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + // when + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, password); + + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 전체 사용자 플로우") + class FullUserFlowE2E { + + @Test + @DisplayName("회원가입 → 조회 → 비밀번호 변경 → 새 비밀번호로 조회") + void fullUserFlow() { + // Step 1: 회원가입 + String loginId = "flowuser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "김철수"); + + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity infoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + assertThat(infoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(infoResponse.getBody().name()).isEqualTo("김철*"); + + // Step 3: 비밀번호 변경 + String newPassword = "NewPassword1!"; + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 4: 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity finalResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + assertThat(finalResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(finalResponse.getBody().loginId()).isEqualTo(loginId); + } + } + + private UserRegisterRequest createRegisterRequest(String loginId, String password, String name) { + return new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + } + + 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 void registerUser(String loginId, String password, String name) { + var request = createRegisterRequest(loginId, password, name); + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + } +} 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/UserApiIntegrationTest.java new file mode 100644 index 000000000..87994f717 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java @@ -0,0 +1,275 @@ +package com.loopers.interfaces.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.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 UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("회원가입 API") + class RegisterApi { + + @Test + @DisplayName("회원가입 성공") + void register_success() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("중복 ID로 회원가입 시 실패") + void register_fail_duplicateId() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + // 첫 번째 가입 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 동일 ID로 재가입 시도 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("필수 필드 누락 시 실패") + void register_fail_missingFields() throws Exception { + var request = new UserRegisterRequest( + "", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 이메일 형식으로 가입 시 실패") + void register_fail_invalidEmail() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "invalid-email" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("내 정보 조회 API") + class GetMyInfoApi { + + @Test + @DisplayName("내 정보 조회 성공") + void getMyInfo_success() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)) + .andExpect(jsonPath("$.name").value("홍길*")) + .andExpect(jsonPath("$.birthday").value("19900515")) + .andExpect(jsonPath("$.email").value("test@example.com")); + } + + @Test + @DisplayName("잘못된 비밀번호로 조회 시 실패") + void getMyInfo_fail_wrongPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void getMyInfo_fail_userNotFound() throws Exception { + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", "notexist") + .header("X-Loopers-LoginPw", "Password1!")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("2자 이름 마스킹 확인") + void getMyInfo_maskedName_2chars() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("홍*")); + } + } + + @Nested + @DisplayName("비밀번호 변경 API") + class UpdatePasswordApi { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() throws Exception { + String loginId = "testuser1"; + String currentPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, currentPassword, "홍길동"); + + var request = new PasswordUpdateRequest(currentPassword, newPassword); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", currentPassword) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 변경된 비밀번호로 조회 확인 + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", newPassword)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("현재 비밀번호 불일치 시 실패") + void updatePassword_fail_wrongCurrentPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("WrongPassword1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "Password1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void updatePassword_fail_samePassword() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + var request = new PasswordUpdateRequest(password, password); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("인증 실패 시 비밀번호 변경 불가") + void updatePassword_fail_authenticationFailed() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("Password1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} 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 deleted file mode 100644 index e542c199a..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java +++ /dev/null @@ -1,156 +0,0 @@ -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 deleted file mode 100644 index ca1bf2f56..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java +++ /dev/null @@ -1,181 +0,0 @@ -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/dto/UserInfoResponseTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java new file mode 100644 index 000000000..6125054a3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.UserQueryUseCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserInfoResponseTest { + + @Test + @DisplayName("생년월일이 yyyyMMdd 형식으로 포맷팅된다") + void from_formatsBirthdayCorrectly() { + // given + var userInfo = new UserQueryUseCase.UserInfoResponse( + "test1234", + "홍길*", + LocalDate.of(1990, 5, 15), + "test@example.com" + ); + + // when + var response = UserInfoResponse.from(userInfo); + + // then + assertThat(response.birthday()).isEqualTo("19900515"); + } + + @Test + @DisplayName("한 자리 월/일은 앞에 0이 붙는다") + void from_formatsBirthdayWithLeadingZeros() { + // given + var userInfo = new UserQueryUseCase.UserInfoResponse( + "test1234", + "홍길*", + LocalDate.of(2000, 1, 5), + "test@example.com" + ); + + // when + var response = UserInfoResponse.from(userInfo); + + // then + assertThat(response.birthday()).isEqualTo("20000105"); + } + + @Test + @DisplayName("모든 필드가 올바르게 매핑된다") + void from_mapsAllFieldsCorrectly() { + // given + var userInfo = new UserQueryUseCase.UserInfoResponse( + "user123", + "김철*", + LocalDate.of(1985, 12, 25), + "kim@example.com" + ); + + // when + var response = UserInfoResponse.from(userInfo); + + // then + assertThat(response.loginId()).isEqualTo("user123"); + assertThat(response.name()).isEqualTo("김철*"); + assertThat(response.birthday()).isEqualTo("19851225"); + assertThat(response.email()).isEqualTo("kim@example.com"); + } +} \ No newline at end of file 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 deleted file mode 100644 index 55a614978..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 54bec699a..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index b98fcefbb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ /dev/null @@ -1,238 +0,0 @@ -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 deleted file mode 100644 index 3aa07c19f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java +++ /dev/null @@ -1,266 +0,0 @@ -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 deleted file mode 100644 index 28ce8d3be..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index 486478c69..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java +++ /dev/null @@ -1,205 +0,0 @@ -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/build.gradle.kts b/build.gradle.kts index dc167f2e7..9c8490b8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,7 +42,6 @@ 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/claudedocs/week3.md b/claudedocs/week3.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/gradle.properties b/gradle.properties index 5ae37ac99..142d7120f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,6 @@ 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