diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index 46222f6b1..1b46b2980 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -114,7 +114,7 @@ ### 4.2 좋아요 - 이미 좋아요한 상품에 다시 좋아요를 누를 경우 오류로 처리한다. -- 좋아요하지 않은 상품에 좋아요 취소를 요청할 경우 오류로 처리한다. +- 좋아요하지 않은 상품에 좋아요 취소를 요청할 경우 아무 동작 없이 성공으로 처리한다. (멱등) - 삭제된 상품에 대한 좋아요는 목록에서 제외한다. ### 4.3 주문 diff --git a/.docs/design/work-log3.md b/.docs/design/work-log3.md new file mode 100644 index 000000000..0b142eb42 --- /dev/null +++ b/.docs/design/work-log3.md @@ -0,0 +1,1288 @@ +# Round 3 - 이커머스 구현 작업 로그 + +## 구현 범위 + +round2에서 설계한 4개 도메인(브랜드, 상품, 좋아요, 주문)을 TDD로 구현한다. + +- 설계 문서: `01-requirements.md`, `02-sequence-diagrams.md`, `03-class-diagram.md`, `04-erd.md` +- 기존 구현: User 도메인 (round1 완료) +- 개발 방식: Red → Green → Refactor + +--- + +## 구현 순서 + +의존 관계 기반으로 순서를 결정한다. + +``` +1. Brand (의존 없음) +2. Product (Brand 의존) +3. Like (Product, User 의존) +4. Order (Product, User 의존, 가장 복잡) +``` + +--- + +## 1. Brand 도메인 + +### 엔티티 + +| 필드 | 타입 | 규칙 | +|------|------|------| +| name | String | 필수, 공백/null 불가 | +| description | String | 선택 | + +- `BaseEntity` 상속 (soft delete) + +### API 목록 + +| 구분 | METHOD | URI | 설명 | +|------|--------|-----|------| +| 사용자 | GET | `/api/v1/brands/{brandId}` | 브랜드 정보 조회 | +| 어드민 | GET | `/api-admin/v1/brands?page=0&size=20` | 브랜드 목록 조회 (페이징) | +| 어드민 | GET | `/api-admin/v1/brands/{brandId}` | 브랜드 상세 조회 | +| 어드민 | POST | `/api-admin/v1/brands` | 브랜드 등록 | +| 어드민 | PUT | `/api-admin/v1/brands/{brandId}` | 브랜드 수정 | +| 어드민 | DELETE | `/api-admin/v1/brands/{brandId}` | 브랜드 삭제 (연쇄) | + +### 구현 태스크 + +- [x] Brand 엔티티 + 도메인 규칙 (guard) +- [x] BrandRepository 인터페이스 + JPA 구현체 +- [x] BrandService (CRUD + soft delete) +- [x] BrandFacade (연쇄 삭제 조율) — AdminBrandV1Controller의 단일 진입점 +- [x] 어드민 API (Controller, DTO, ApiSpec) +- [x] 사용자 API (Controller, DTO, ApiSpec) +- [x] 단위 테스트 (엔티티, 서비스) +- [x] E2E 테스트 +- [ ] `http/commerce-api/brand-v1.http` 파일 작성 ← **미작성** + +### 비즈니스 규칙 + +- 브랜드 삭제 시 → 소속 상품 soft delete → 상품의 좋아요 hard delete (연쇄) +- 삭제된 브랜드는 사용자에게 노출되지 않음 +- 연쇄 삭제는 BrandFacade에서 조율 (시퀀스 다이어그램 참조) +- BrandFacade는 @Transactional로 원자적 처리 + +--- + +## 2. Product 도메인 + +### 엔티티 + +| 필드 | 타입 | 규칙 | +|------|------|------| +| brandId | Long | 필수, 등록된 브랜드 참조 (변경 불가) | +| name | String | 필수 | +| description | String | 선택 | +| price | Integer | 필수, 원 단위 정수 | +| stockQuantity | Integer | 필수, 0 이상 | +| visibility | Visibility (inner enum) | 필수, 기본값 VISIBLE | + +- `BaseEntity` 상속 (soft delete) +- FK 제약 미사용, 애플리케이션 레벨 검증 + +### API 목록 + +| 구분 | METHOD | URI | 설명 | +|------|--------|-----|------| +| 사용자 | GET | `/api/v1/products?brandId=&sort=&page=&size=` | 상품 목록 조회 | +| 사용자 | GET | `/api/v1/products/{productId}` | 상품 상세 조회 | +| 어드민 | GET | `/api-admin/v1/products?page=0&size=20&brandId=` | 상품 목록 조회 (페이징) | +| 어드민 | GET | `/api-admin/v1/products/{productId}` | 상품 상세 조회 | +| 어드민 | POST | `/api-admin/v1/products` | 상품 등록 | +| 어드민 | PUT | `/api-admin/v1/products/{productId}` | 상품 수정 | +| 어드민 | DELETE | `/api-admin/v1/products/{productId}` | 상품 삭제 | + +### 구현 태스크 + +- [x] Product 엔티티 + 도메인 규칙 (guard, visibility 필드) +- [x] ProductRepository 인터페이스 + JPA 구현체 +- [x] ProductService (CRUD + soft delete) — Command 패턴 적용 (`ProductCreateCommand`, `ProductUpdateCommand`) +- [x] 어드민 API (Controller, DTO) +- [x] 사용자 API (Controller, DTO) — 정렬/brandId 필터링 포함 +- [x] 단위 테스트 (엔티티, 서비스) +- [x] E2E 테스트 +- [ ] `http/commerce-api/product-v1.http` 파일 작성 ← **미작성** +- [ ] `sort=LIKES_DESC` 정렬 실제 구현 ← **미완**: Like 도메인 구현 후 QueryDSL LEFT JOIN + COUNT + GROUP BY로 교체 필요 (현재 InMemory에서 id 기준으로 대체) + +### 정렬 기준 (사용자 상품 목록) + +| sort 파라미터 | 설명 | +|---------------|------| +| `latest` | 최신순 (기본값) | +| `price_asc` | 가격 낮은순 | +| `likes_desc` | 좋아요 많은순 | + +### 페이징 기본값 + +- `page=0`, `size=20`, `sort=latest` + +### 구현 메모 + +- `likes_desc` 정렬은 집계 필요: QueryDSL 기준 `LEFT JOIN + COUNT + GROUP BY` +- 상품 목록 조회의 동적 쿼리(brandId 필터 + 정렬 조합 + 페이징)에 QueryDSL 사용 결정 +- QueryDSL은 프로젝트에 세팅 완료 상태 (JPAQueryFactory Bean 등록됨), 이번 라운드에서 처음 적용하며 학습 + +### 비즈니스 규칙 + +- 상품은 이미 등록된(삭제되지 않은) 브랜드에만 등록 가능 +- 소속 브랜드 변경 불가 +- 상품 삭제 시 → 해당 상품의 좋아요 hard delete +- 삭제된 상품이 포함된 과거 주문은 스냅샷으로 정상 조회 + +### Product.visibility 필드 추가 결정 + +#### 고민 배경 + +상품 생성 시 `stockQuantity = 0` 허용 여부를 논의하다가, 재고는 있지만 +관리자가 상품 수정 등의 이유로 일시적으로 노출을 막고 싶은 케이스를 발견. + +#### 검토한 선택지 + +| 선택지 | 결론 | +|--------|------| +| `stockQuantity = 0`으로 품절 표현 | 유지 | +| `isVisible: Boolean` | 확장 시 enum 교체 비용 발생, 표현력 부족으로 제외 | +| `status: ACTIVE / OUT_OF_STOCK / HIDDEN / DISCONTINUED` | OUT_OF_STOCK은 stockQuantity와 중복, DISCONTINUED는 soft delete와 역할 겹침 | +| `visibility: VISIBLE / HIDDEN` | 채택 | + +#### 결정 및 근거 + +- `visibility` enum 필드 추가 (`VISIBLE / HIDDEN`) +- `OUT_OF_STOCK` 제외 이유: `stockQuantity = 0`으로 이미 표현 가능, 두 필드 동기화 책임 생김 +- `DISCONTINUED` 제외 이유: soft delete(`deletedAt`)와 역할 중복 +- boolean(`isVisible`) 대신 enum을 선택한 이유: `VISIBLE_AFTER_LOGIN` 등 노출 조건 확장 시 값 추가만으로 대응 가능 + +#### 정책 + +| 값 | 노출 | 주문 | +|----|------|------| +| `VISIBLE` | O | 재고 > 0일 때 가능 | +| `HIDDEN` | X | 불가 | + +- 상품 생성 시 기본값: `VISIBLE` +- 사용자 API: `visibility = VISIBLE && deletedAt IS NULL` 인 상품만 반환 +- 어드민 API: `visibility` 무관하게 반환 + +--- + +## 3. Like 도메인 + +### 엔티티 + +| 필드 | 타입 | 규칙 | +|------|------|------| +| id | Long | PK | +| userId | Long | 필수 | +| productId | Long | 필수 | +| createdAt | ZonedDateTime | 자동 (`@PrePersist`) | + +- **BaseEntity 상속하지 않음** — Brand/Product와 달리 **hard delete** 정책 +- `(userId, productId)` 복합 UNIQUE 제약 → `@UniqueConstraint`로 선언 +- createdAt만 존재 (updatedAt, deletedAt 없음) + +### API 목록 + +| 구분 | METHOD | URI | 설명 | +|------|--------|-----|------| +| 사용자 | POST | `/api/v1/products/{productId}/likes` | 좋아요 등록 | +| 사용자 | DELETE | `/api/v1/products/{productId}/likes` | 좋아요 취소 | +| 사용자 | GET | `/api/v1/users/me/likes` | 내 좋아요 목록 조회 | + +- 인증: `X-Loopers-LoginId` / `X-Loopers-LoginPw` 헤더 +- `/me` 패턴 — 로그인한 본인의 목록만 조회 가능, 타인 접근 시나리오 자체 없음 + +### 구현 컴포넌트 + +#### 신규 파일 + +| 파일 | 패키지 | 설명 | +|------|--------|------| +| `Like.java` | `domain/like/` | 엔티티 (BaseEntity 미상속) | +| `LikeRepository.java` | `domain/like/` | 인터페이스 | +| `LikeJpaRepository.java` | `infrastructure/like/` | extends JpaRepository | +| `LikeRepositoryImpl.java` | `infrastructure/like/` | 구현체 | +| `LikeInfo.java` | `application/like/` | record (id, userId, productId, createdAt) | +| `LikeService.java` | `application/like/` | register, cancel, getLikesByUserId, deleteAllByProductIds | +| `LikeFacade.java` | `application/like/` | ProductService 조율 (상품 유효성 검증) | +| `LikeV1Dto.java` | `interfaces/api/like/` | LikeResponse record | +| `LikeV1Controller.java` | `interfaces/api/like/` | 사용자 API | + +#### 기존 파일 수정 + +| 파일 | 변경 내용 | +|------|----------| +| `BrandFacade.java` | `delete()` — 좋아요 연쇄 삭제 추가 (`LikeService.deleteAllByProductIds`) | +| `ProductService.java` | `getProductIdsByBrandId()` 메서드 추가 (BrandFacade에서 사용) | +| `ErrorType.java` | `ALREADY_LIKED` (BAD_REQUEST) 추가 | + +### 구현 태스크 + +- [x] Like 엔티티 (BaseEntity 미상속, 직접 필드 정의) +- [x] LikeRepository 인터페이스 + JPA 구현체 +- [x] LikeService (등록, 취소, 목록 조회, 일괄 삭제) +- [x] LikeFacade (ProductService로 상품 유효성 검증) +- [x] 사용자 API (Controller, DTO) +- [x] BrandFacade.delete() 연쇄 삭제 확장 +- [x] ProductService.getProductIdsByBrandId() 추가 +- [x] 단위 테스트 — `InMemoryLikeRepository` (save + findByUserIdAndProductId만), `LikeServiceTest` (ALREADY_LIKED guard 1건) +- [x] E2E 테스트 (LikeV1ApiE2ETest) +- [x] `http/commerce-api/like-v1.http` 파일 작성 + +### 구현 중 추가 변경 사항 + +- `ErrorType.ALREADY_LIKED` 추가 (BAD_REQUEST) — 중복 좋아요 시 명확한 에러 코드 반환 +- `UserService.getUserId(loginId, loginPw): Long` 추가 — 자격증명 검증 후 userId 반환 (UserInfo에 id 추가 없이 처리) +- `AdminBrandV1ApiE2ETest`에 좋아요 연쇄 삭제 케이스 추가 +- `BrandFacadeTest` — 좋아요 연쇄 삭제 케이스는 E2E로 이관, `LikeService`는 `mock(LikeService.class)`로 생성자 채움 +- 좋아요 목록 조회 URL 변경: `GET /api/v1/users/{userId}/likes` → `GET /api/v1/users/me/likes` +- `LikeTest.java` 삭제 — Like 엔티티에 guard 로직 없어 유의미한 단위 테스트 불가 +- 좋아요 취소 멱등화 — 미존재 취소 시 NOT_FOUND → 200 OK (DELETE 멱등 원칙) + +### 구현 순서 (TDD) + +``` +1. LikeTest (Red) → Like 엔티티 (Green) +2. InMemoryLikeRepository 작성 +3. LikeServiceTest (Red) → LikeService + LikeInfo (Green) +4. LikeRepositoryImpl + LikeJpaRepository +5. LikeFacade +6. LikeV1Controller + LikeV1Dto +7. ProductService.getProductIdsByBrandId() 추가 +8. BrandFacade.delete() 수정 — LikeService 연쇄 삭제 +9. LikeV1ApiE2ETest +10. like-v1.http +``` + +### 설계 고민 + +#### /me URL 패턴 채택 이유 + +초기 설계: `GET /api/v1/users/{userId}/likes` +→ 변경: `GET /api/v1/users/me/likes` + +이유: +- `{userId}`가 DB PK 노출 (IDOR 위험) +- 소유권 비교 로직(`requesterId.equals(userId)`) 필요 → 복잡도 증가 +- 어차피 본인 목록만 조회 가능하므로 pathVariable 자체가 불필요 +- 기존 `/api/v1/users/me` 패턴과 일관성 + +#### 취소 멱등화 (NOT_FOUND → 200 OK) + +클라이언트는 좋아요 버튼의 현재 상태를 이미 알고 있으며, 취소 요청의 목적은 "좋아요가 없는 상태를 보장하는 것"이다. + +만약 이미 취소된 상태라면, 이는 오류라기보다 이미 목적이 달성된 상태에 가깝다. 에러를 반환하더라도 클라이언트가 추가로 취할 수 있는 행동은 없으며, 오히려 불필요한 예외 처리는 복잡도와 UX 부담만 증가시킨다. + +따라서 멱등성(idempotency)을 유지하는 관점과 서비스 품질 측면에서 200 OK를 반환하는 것이 더 적절하다고 판단하였다. `requirements 4.2` 반영 완료. + +#### likeCount 미구현 — 설계 고민 + +상품 조회 시 좋아요 수 노출이 필요하나 현재 미구현. 검토한 선택지: + +| 방식 | 정합성 | 성능 | 복잡도 | +|------|--------|------|--------| +| 실시간 COUNT | 완벽 | 낮음 | 낮음 | +| Product.likeCount 카운터 | 관리 필요 | 높음 | 중간 | +| Redis 카운터 | 최종 일관성 | 매우 높음 | 높음 | + +`Product.likeCount`는 denormalized cache — `likes` 테이블이 source of truth이고 카운터는 파생 데이터. 같은 트랜잭션 안에서 관리하면 정합성 유지 가능하나, 브랜드 삭제 시 cascade 삭제 등에서 감산 누락 위험. 좋아요 수는 approximate count로 허용되는 서비스가 대부분. + +→ **TODO**: `Product.likeCount` 카운터 컬럼 방향으로 구현 예정 + +#### 인증 방식 한계 + +현재 구조: Controller에서 `X-Loopers-LoginId` + `X-Loopers-LoginPw` 헤더를 직접 받아 `UserService.getUserId()` 호출. 이는 임시방편으로, 인증 책임이 Controller 레이어에 산재되어 있음. + +올바른 방향: `HandlerInterceptor` + `ArgumentResolver`로 인증 분리 → `@LoginUser Long userId`로 주입. 완료 시 Controller에서 `UserService` 의존 제거 가능. + +→ **TODO**: HandlerInterceptor + ArgumentResolver 구현 예정 + +#### BrandFacadeTest에서 LikeService mock 사용 이유 + +`BrandFacade` 생성자가 `LikeService`를 필수 의존성으로 받음. `BrandFacadeTest`는 brand/product soft delete만 검증하고 좋아요 cascade는 `AdminBrandV1ApiE2ETest`에서 커버. 관심사와 무관한 의존성을 채우기 위해 `mock(LikeService.class)` 사용. + +--- + +### 설계 결정 사항 + +#### Brand/Product와의 차이 + +| 항목 | Brand / Product | Like | +|------|-----------------|------| +| BaseEntity 상속 | O (soft delete) | X (hard delete) | +| 타임스탬프 | createdAt, updatedAt, deletedAt | createdAt만 | +| 복합 UNIQUE | 없음 | (userId, productId) | +| Facade 역할 | 도메인 조율 | 상품 유효성 검증 | + +#### LikeFacade 책임 + +``` +register: ProductService.getVisibleProduct() 검증 → LikeService.register() +cancel: LikeService.cancel() 직접 위임 +getLikedProductsByUserId: LikeService.getLikesByUserId() → getVisibleProductsByIds()로 Map 조합 → LikedProductInfo 반환 +``` + +#### BrandFacade.delete() 연쇄 삭제 흐름 + +``` +BrandFacade.delete(brandId) + 1. productService.getProductIdsByBrandId(brandId) ← 신규 메서드 + 2. likeService.deleteAllByProductIds(productIds) ← hard delete + 3. productService.deleteAllByBrandId(brandId) ← soft delete + 4. brandService.delete(brandId) ← soft delete +``` + +### 비즈니스 규칙 + +- 같은 상품에 중복 좋아요 불가 → BAD_REQUEST +- 좋아요하지 않은 상품에 취소 요청 → 200 OK (멱등, 무시) +- HIDDEN / 삭제된 상품에 좋아요 등록 시 → NOT_FOUND +- 좋아요/취소는 본인만 가능 +- 취소 시 이력 미보존 (hard delete) + +### Like 도메인 후속 개선 (2026-02-25) + +#### BrandFacadeTest — 좋아요 연쇄 삭제 실제 검증으로 전환 + +기존: `LikeService`를 `mock(LikeService.class)`으로 주입해 연쇄 삭제 미검증 +변경: `InMemoryLikeRepository` + `LikeService` 실제 인스턴스 사용, `deletesAllLikes_whenBrandIsDeleted` 케이스 추가 + +assertion 원칙 정리: +- `containsExactly` → `doesNotContain` 으로 변경 — 테스트 의도("삭제됐는가")에 집중, 순서/부수 검증 배제 +- userId `1L` 리터럴 → `long userId = 1L;` 변수 추출 (의미 명확화) + +#### 좋아요 목록 조회 — 삭제/HIDDEN 상품 필터링 추가 + +요구사항 4.2항: "삭제된 상품에 대한 좋아요는 목록에서 제외한다" + +- `ProductService.getProductsByIds` → `getVisibleProductsByIds`로 리네이밍 +- 변경 이유: HIDDEN 상태 상품도 필터링 필요 — 관리자가 HIDDEN으로 설정한 의도는 "사용자에게 노출 안 함"이며 좋아요 목록도 포함 +- 구현: `findAllByIdInAndDeletedAtIsNull` 결과에 `.filter(p -> visibility == VISIBLE)` 추가 (service layer 필터링) +- `LikeFacadeTest` 신설 — 삭제/HIDDEN 상품 좋아요 제외 단위 테스트 +- `LikeV1ApiE2ETest` — 삭제/HIDDEN 상품 좋아요 제외 E2E 케이스 추가 + +#### 좋아요 목록 응답 — 상품 정보 포함으로 변경 + +요구사항: "사용자가 좋아요한 **상품 목록**을 확인할 수 있다" + +기존 응답: `LikeResponse(id, userId, productId, createdAt)` — like 메타데이터만 +변경 응답: `LikedProductResponse(likeId, productId, productName, price, likedAt)` — 상품 정보 포함 + +| 파일 | 변경 | +|------|------| +| `LikedProductInfo.java` (신규) | application 레이어 조합 DTO `(likeId, ProductInfo product, likedAt)` | +| `LikeV1Dto.LikedProductResponse` (신규) | interfaces 레이어 응답 DTO | +| `LikeFacade.getLikedProductsByUserId` | `List` → `List` 반환, Map으로 N+1 없이 조합 | +| `LikeV1Controller.getLikes` | 반환 타입 변경 | + +설계 결정 — Facade 레벨 조합: +- `LikeInfo`는 Like 메타데이터 역할 유지 (단일 책임) +- Facade에서 `likeService.getLikesByUserId` + `productService.getVisibleProductsByIds` 결과를 Map으로 조합 +- `getVisibleProductsByIds`가 이미 삭제/HIDDEN 필터링을 담당하므로 조합과 필터링이 한 번에 처리됨 +- N+1 없이 IN 쿼리 한 번으로 처리 + +--- + +### SignUpValidator 제거 — 검증 로직 레이어 분리 (2026-02-25) + +#### 배경 + +`SignUpValidator` (`domain/user/`)는 성격이 다른 검증 두 가지를 혼합: +1. `userRepository.findByLoginId()` — DB 조회 필요한 애플리케이션 레벨 제약 +2. `birthDate.isAfter(now())` — 외부 상태 없는 순수 도메인 규칙 +3. `PasswordPolicyValidator.validate()` — 순수 도메인 규칙 + +#### 변경 내용 + +| 검증 | 이전 위치 | 이후 위치 | 이유 | +|------|-----------|-----------|------| +| birthDate 미래 검증 | `SignUpValidator` | `User.validateBirthDate()` | 엔티티 불변식, User 생성 시점에 항상 적용 | +| loginId 중복 확인 | `SignUpValidator` | `SignUpService.signUp()` | DB 조회 필요, 트랜잭션 내 처리 | +| 비밀번호 정책 | `SignUpValidator` | `SignUpService.signUp()` | 유스케이스 흐름의 일부 | + +#### 파일 변경 + +| 파일 | 변경 | +|------|------| +| `SignUpValidator.java` | 삭제 | +| `SignUpValidatorTest.java` | 삭제 | +| `SignUpService.java` | `SignUpValidator` 의존 제거, loginId 중복/비밀번호 정책 직접 처리 | +| `SignUpServiceTest.java` | `SignUpValidator` 제거, Nested 구조로 개편, 5개 케이스 추가 | +| `UserTest.java` | birthDate 미래 케이스 추가 | + +--- + +## 4. Order 도메인 + +### 엔티티 + +**Order** + +| 필드 | 타입 | 규칙 | +|------|------|------| +| userId | Long | 필수 | +| status | Order.Status (inner enum) | ORDERED | +| totalAmount | Long | OrderItem 합산 금액 | + +- `BaseEntity` 상속 (soft delete) + +**OrderItem** + +| 필드 | 타입 | 규칙 | +|------|------|------| +| orderId | Long | 필수 | +| productId | Long | 원본 참조 | +| productName | String | 스냅샷 | +| price | Integer | 스냅샷 | +| quantity | Integer | 1 이상 | + +- `BaseEntity` 상속 +- Order와 컴포지션 관계 + +### API 목록 + +| 구분 | METHOD | URI | 설명 | +|------|--------|-----|------| +| 사용자 | POST | `/api/v1/orders` | 주문 생성 | +| 사용자 | GET | `/api/v1/orders?startAt=&endAt=` | 주문 목록 조회 (기간별) | +| 사용자 | GET | `/api/v1/orders/{orderId}` | 주문 상세 조회 | +| 어드민 | GET | `/api-admin/v1/orders?page=0&size=20` | 주문 목록 조회 (페이징) | +| 어드민 | GET | `/api-admin/v1/orders/{orderId}` | 주문 상세 조회 | + +### 구현 태스크 + +→ 상세 내용은 **섹션 7** 참조 + +- [x] Order 엔티티 + Order.Status inner enum +- [x] OrderItem 엔티티 +- [x] OrderRepository, OrderItemRepository 인터페이스 + JPA 구현체 +- [x] OrderService (주문 구조 검증, 주문 저장) +- [x] OrderFacade (상품 조회 → 재고 차감 → 주문 저장 조율) +- [x] 사용자 API (Controller, DTO) +- [x] 어드민 API (Controller, DTO) +- [x] 단위 테스트 +- [x] E2E 테스트 +- [ ] `http/commerce-api/order-v1.http` 파일 작성 ← **미작성** + +### 주문 생성 흐름 (시퀀스 다이어그램 기반) + +``` +1. 주문 항목 검증 (빈 항목, 중복 상품, 수량 ≥ 1) — OrderService 책임 +2. 상품 목록 조회 (미존재/삭제 상품 시 오류) — ProductService 책임 +3. 재고 검증 + 차감 (메모리) — OrderFacade 조율 +4. 재고 부족 시 → 전체 주문 거부 + 부족 상품 정보 응답 +5. 재고 반영 (saveAll) — ProductService +6. 주문 저장 (스냅샷 포함, ORDERED 상태) — OrderService +``` + +### 구현 메모 + +- 동시성/멱등성은 범위 밖 (단일 스레드 기준). 재고 차감 경쟁 조건은 추후 고도화 단계에서 해결. + +### 비즈니스 규칙 + +- 주문 항목 중 하나라도 재고 부족 → 전체 주문 거부 +- 상품 정보(상품명, 가격) 스냅샷 저장 +- 주문 생성 시 즉시 ORDERED 상태 +- 같은 상품 중복 주문 항목 불가 +- 주문 수량 1개 이상 + +--- + +## 공통 참고사항 + +### 레이어별 패키지 구조 + +``` +domain/{도메인}/ → 엔티티, Repository 인터페이스, 도메인 순수 로직 (Validator 등) +application/{도메인}/ → Service, Facade, Command, Info +infrastructure/{도메인}/ → RepositoryImpl, JpaRepository +interfaces/api/{도메인}/ → Controller, ApiSpec, DTO +``` + +### Service 레이어 위치 — `application/` 배치 결정 + +#### 결론 + +`BrandService`, `ProductService` 등은 **`application/` 하위에 배치한다.** + +#### 근거 (2026-02-24 토론으로 정제) + +초기에는 "domain에 두면 domain→domain 의존이 생긴다"는 이유로 application으로 이동했으나, 이 근거 자체는 충분하지 않다. Facade 패턴이 적용된 이후 cross-domain 호출은 Facade 레이어에서 처리되므로, 이 우려는 해소됐다. + +실제로 application에 두는 **더 본질적인 이유**는 서비스가 담고 있는 내용의 성격이다: + +- `getAdminProducts` / `getProducts` — actor(어드민/사용자)에 따라 다른 쿼리를 반환. **역할 인식이 있는 유스케이스 조율**로, 순수 도메인 불변식이 아니다. +- `getVisibleProduct` — visibility 정책 적용. 도메인 규칙에 가깝지만, 어디서 호출하느냐(사용자 API)에 의존하는 컨텍스트가 있다. +- CRUD 흐름 전체 — "존재 확인 → 상태 변경 → 저장"의 오케스트레이션. 엔티티 자체의 불변식이 아닌, 유스케이스 단위의 흐름이다. + +> 순수 도메인 서비스라면 actor를 모르고, 조건을 파라미터로 받아야 한다. +> 현재 서비스들은 메서드명/로직에 actor 인식이 녹아 있으므로 application 배치가 더 정직하다. + +만약 domain에 두려면 actor 인식을 걷어내고 메서드를 role-agnostic하게 재설계해야 하나, 현재 복잡도에서는 과도한 추상화다. + +#### 참고: DDD vs Clean Architecture 해석 차이 + +DDD 관점에서는 domain service가 repository를 직접 호출하는 구조가 자연스럽고(ExampleService 템플릿 참고), 멘토님(Devin) CASE C도 이 방향이다. 그러나 이 프로젝트의 현재 서비스들은 순수 도메인 서비스라기보다 유스케이스 흐름에 가깝기 때문에 application 배치를 유지한다. **위치보다 책임이 중요하다.** + +#### 이동 결과 + +| 클래스 | 이전 위치 | 이후 위치 | +|--------|-----------|-----------| +| `BrandService` | `domain/brand/` | `application/brand/` | +| `ProductService` | `domain/product/` | `application/product/` | +| `ProductSort` | `domain/product/` | `application/product/` | +| `SignUpService` | `domain/user/` | `application/user/` | +| `UserService` | `domain/user/` | `application/user/` | + +`domain/` 에는 엔티티, Repository 인터페이스, Validator 등 순수 도메인 객체만 잔류. + +#### 테스트 패키지도 정합성 맞춤 + +테스트 파일은 대상 클래스의 패키지 구조를 그대로 미러링하는 것이 원칙이다. +Service 이동에 따라 테스트 파일도 동일하게 `application/` 하위로 정렬했다. + +이때 단순 생성/삭제가 아닌 **`git mv`** 를 사용해 git 히스토리를 rename으로 보존했다. +`git log --follow` 로 파일 이동 전 이력까지 추적 가능하다. + +```bash +git mv domain/brand/BrandServiceTest.java application/brand/BrandServiceTest.java +``` + +### 사용자 식별 방식 + +- 사용자: `@RequestHeader("X-Loopers-LoginId")`, `@RequestHeader("X-Loopers-LoginPw")` +- 어드민: `@RequestHeader("X-Loopers-Ldap")` — 값: `loopers.admin` + +### E2E 테스트 최소 시나리오 + +- Brand: 등록 → 조회 → 수정 → 삭제(연쇄 삭제 포함) +- Product: 등록 → 목록 정렬/필터 → 상세 → 삭제 +- Like: 등록 성공 → 중복 오류 → 취소 성공 → 미존재 오류 +- Order: 정상 주문 성공 → 재고 부족 전체 실패 +- 공통: 에러 코드/메시지 응답 검증 포함 + +### 응답 정책 + +- 타인의 주문/좋아요 목록 접근 시 403이 아닌 404로 응답 (리소스 존재 여부 비노출) + +### 서비스 반환 타입 정책 + +Service/Facade는 도메인 엔티티를 직접 반환하지 않고, application 레이어의 Info DTO로 변환해 반환한다. + +``` +Service/Facade → Info DTO (application 레이어) → Controller → Response DTO (interfaces 레이어) +``` + +#### 도입 이유 + +1. **트랜잭션 경계**: `@Transactional` 메서드 종료 후 엔티티는 detached 상태가 된다. OSIV(Open Session In View)를 끄면(실무 권장) Controller에서 lazy 필드 접근 시 `LazyInitializationException` 발생 +2. **API-도메인 결합 방지**: 엔티티 필드 변경이 API 응답에 즉시 영향을 주는 묵시적 결합 제거 +3. **일관성**: 단순/복잡 케이스를 구분하는 모호한 기준 없이 모든 도메인에 동일 패턴 적용 + +#### Info DTO 위치 + +``` +application/{도메인}/BrandInfo.java +application/{도메인}/ProductInfo.java +``` + +#### 구현 원칙 + +- Service 내부에서는 엔티티로 작업 (`private findById()`, `private findNonDeletedById()`) +- public 메서드 반환 시점에만 `Info.from(entity)`로 변환 +- `interfaces` 레이어 DTO는 `from(Info)`로만 생성 (`from(Entity)` 금지) + +### 테스트 전략 + +InMemoryRepository 기반 단위 테스트와 E2E 테스트의 역할을 명확히 구분한다. + +| 테스트 종류 | 대상 | 예시 | +|------------|------|------| +| 엔티티 단위 테스트 | 도메인 guard, 상태 전이 | `price < 0 → BAD_REQUEST` | +| Service 단위 테스트 | 순수 비즈니스 규칙 (InMemory 활용) | `존재하지 않는 브랜드로 등록 → NOT_FOUND` | +| E2E 테스트 | QueryDSL 의존 쿼리 동작 | 삭제 필터, brandId 필터, 정렬 | + +**InMemoryRepository의 한계:** +- QueryDSL 동적 쿼리(필터링, 정렬, 페이징)는 실구현과 동작이 다를 수 있음 +- 따라서 QueryDSL에 의존하는 테스트는 InMemory로 검증하지 않고 E2E 테스트에서 커버 + +**실제 적용:** +- `ProductServiceTest.GetProducts`, `BrandServiceTest.GetBrands` → 제거 (E2E에서 커버) +- 나머지 비즈니스 규칙 테스트는 ServiceTest에 유지 + +- 각 도메인 구현 완료 시 `http/*.http` 파일에 API 테스트 추가 + +--- + +## 구현 중 확립된 패턴 (round3 추가) + +### PageResponse DTO 도입 + +#### 배경 + +E2E 테스트에서 `Page` 인터페이스를 `TestRestTemplate`으로 역직렬화할 수 없는 문제 발견. +`Page`는 인터페이스이므로 Jackson이 구체 타입을 알 수 없어 역직렬화 실패. + +#### 해결 + +`PageResponse` record DTO 생성 후 컨트롤러 응답 타입으로 사용. + +```java +// interfaces/api/PageResponse.java +public record PageResponse( + List content, int page, int size, long totalElements, int totalPages +) { + public static PageResponse from(Page page) { + return new PageResponse<>(page.getContent(), page.getNumber(), + page.getSize(), page.getTotalElements(), page.getTotalPages()); + } +} +``` + +#### 적용 범위 + +- `AdminBrandV1Controller.getBrands()` → `ApiResponse>` +- `AdminProductV1Controller.getProducts()` → `ApiResponse>` +- `ProductV1Controller.getProducts()` → `ApiResponse>` + +#### 테스트에서의 활용 + +```java +ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); +List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id).toList(); +assertThat(ids).contains(productA.getId(), productB.getId()); +``` + +### Domain 엔티티가 Application 객체를 참조하면 안 된다 + +#### 문제 + +`User.create(SignUpCommand command, String encodedPassword)` 팩토리 메서드가 `SignUpCommand`(application 계층)를 파라미터로 받고 있어 의존 방향이 역전됨. + +``` +Domain → Application ← 위반 +``` + +#### 올바른 방향 + +``` +Application → Domain ← 정상 +``` + +Application이 Command를 언팩해서 프리미티브로 넘기고, Domain은 값만 받는다. + +```java +// ✅ Domain — Application을 모름 +public static User create(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) + +// ✅ Application — 언팩 책임 +User user = User.create(command.loginId(), encoded, command.name(), command.birthDate(), command.email()); +``` + +현재 `User.create(SignUpCommand)`는 기술 부채로 남아 있음. 추후 수정 예정. + +--- + +### Command 패턴 (ProductCreateCommand / ProductUpdateCommand) + +Service 메서드 파라미터를 개별 값 대신 Command 객체로 감싸 응집도를 높임. + +``` +application/product/ProductCreateCommand.java +application/product/ProductUpdateCommand.java +``` + +ProductService 메서드 시그니처: +- `register(ProductCreateCommand command)` +- `update(Long id, ProductUpdateCommand command)` + +User 도메인에 이미 `SignUpCommand`, `UpdatePasswordCommand`로 동일 패턴 존재. + +### E2E 테스트 단언 원칙 (ID 기반 비교) + +목록 조회 테스트에서 문자열 포함 여부(`contains("에어맥스")`)가 아닌 타입 안전한 ID 비교를 원칙으로 함. + +이유: 문자열 비교는 price 등 다른 JSON 필드에서 우연히 매칭될 위험이 있음. + +```java +// ✅ 올바른 방식 +List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id).toList(); +assertThat(ids).contains(productA.getId()); + +// ❌ 피해야 할 방식 +assertThat(responseBody).contains("에어맥스"); +``` + +### BrandFacade를 AdminBrandV1Controller의 단일 진입점으로 결정 + +#### 고민 배경 + +BrandFacade 초기 구현 시 delete만 Facade를 통하고, 나머지 CRUD는 BrandService를 직접 호출하는 구조가 됐다. + +```java +// ❌ 어색한 구조 +AdminBrandV1Controller + ├── BrandFacade (delete만) + └── BrandService (나머지 전부) +``` + +Controller가 두 의존성을 동시에 갖는 건 "BrandFacade가 불완전한 진입점"이라는 신호다. + +#### 잘못된 표현 수정 + +처음에 "도메인 서비스 간 결합 증가"라고 표현했지만, `BrandService`와 `ProductService`는 모두 `application` 패키지에 있는 **Application 서비스**다. "도메인 서비스"라는 표현은 틀렸다. + +#### BrandFacade가 필요한 진짜 이유 + +Application 서비스끼리 참조 자체는 이론적으로 가능하다. 그러나 이 프로젝트에서는 **순환 의존**이 발생한다. + +``` +ProductService → BrandService (이미 존재) +BrandService → ProductService (추가 시) + ↑___________________________↓ 순환! +``` + +`ProductService`가 이미 `BrandService`를 주입받고 있으므로, `BrandService`가 `ProductService`를 참조하면 Spring 빈 생성 시점에 순환 의존으로 실패한다. `BrandFacade`는 이 순환을 피하기 위한 상위 조율자다. + +#### 결정: BrandFacade를 단일 진입점으로 확장 + +```java +// ✅ 단일 진입점 +AdminBrandV1Controller → BrandFacade → BrandService + → ProductService (delete 시에만) +``` + +- Controller는 `BrandFacade`만 알고, 나머지는 Facade가 내부적으로 BrandService에 위임 +- 단순 위임이더라도 조율 로직이 추가될 때 수정 범위가 Facade 내로 한정됨 +- BrandService는 Brand 도메인 순수 로직만 유지 + +#### delete만 @Transactional이 필요한 이유 + +단순 위임 메서드(register, getBrand, getBrands, update)는 BrandService의 `@Transactional`이 그대로 적용되므로 Facade에 추가 선언 불필요. delete만 두 서비스를 가로질러 원자성이 필요하므로 Facade에서 `@Transactional`을 선언해 하나의 트랜잭션으로 묶는다. + +### DDD 아키텍처 고민 — Service 위치와 레이어 간 의존 (2026-02-24) + +#### 배경 + +멘토님(Devin) DDD 라이브 세션 이후 현재 brand/product 패키지 구조가 멘토님 기준에 맞는지 검토함. + +세션 핵심: `interfaces / application / domain / infra` 4레이어. Repository를 호출하고 도메인 로직을 처리하는 Service는 domain에 있어야 한다. Facade는 도메인 서비스들을 조합해 애플리케이션 비즈니스를 구현한다. (CASE C 선호) + +→ 전체 내용은 `.docs/design/ddd_session_by_devin.md` 참조 + +#### 검토 결과 + +멘토님 CASE C 기준으로 보면 `BrandService` / `ProductService`는 `domain/` 으로 이동해야 함. 그러나 Codex 피드백에서 다른 관점 제시: + +> "단순 CRUD 서비스(저장/조회 위주, 규칙 거의 없음)는 application에 두는 게 맞다. 도메인에 둘 만한 것은 CRUD를 넘는 규칙이 생겼을 때다." + +두 관점의 차이: +- **멘토님(Devin)**: 기준 = "기능의 완결성" — Repository를 호출하는 단위가 단독으로 완결된 기능이 되려면 domain에 있어야 한다 +- **Codex**: 기준 = "로직의 성격" — CRUD는 유스케이스 흐름이므로 application이 적절하다 + +#### 결론: `BrandService` / `ProductService` application 유지 (Codex 의견 수용) + +현재 두 서비스는 사실상 CRUD 조율 역할이고, 멘토님이 싫어한 핵심 문제("뭐든 다 아는 뚱뚱한 서비스")는 Facade 분리로 이미 해소됨. + +→ **진짜 문제는 `ProductService → BrandService` 직접 의존** — 동일 레이어 Service끼리 의존으로 순환 위험 + 멘토님 불호 패턴("파사드가 파사드를 콜하는 구조")에 해당 + +#### 해결: ProductFacade 신설로 브랜드 검증 로직 인양 + +--- + +### ProductFacade 신설 — ProductService의 BrandService 의존 제거 (2026-02-24) + +#### 문제 + +``` +ProductService → BrandService (동일 application 레이어 직접 의존) +BrandFacade → ProductService +``` + +- "브랜드 검증 후 상품 등록"은 두 도메인을 조합하는 **애플리케이션 비즈니스** → Facade 책임 +- ProductService에 BrandService가 있으면 Domain 서비스의 단독 완결성이 훼손되고, BrandFacade 확장 시 순환 의존 위험 + +#### 변경 내용 + +| 파일 | 변경 | +|------|------| +| `ProductFacade` | 신설. `register()` 시 `brandService.getBrand()` 검증 후 `productService.register()` 위임 | +| `ProductService` | `BrandService` 의존 제거. `register()`에서 브랜드 검증 로직 제거 | +| `AdminProductV1Controller` | `ProductService` → `ProductFacade` 단일 진입점으로 전환 | +| `ProductServiceTest` | BrandService 관련 setUp 제거, 브랜드 검증 테스트 제거. BRAND_ID 상수로 단순화 | +| `ProductFacadeTest` | 신설. 브랜드 검증 테스트 2건 이동 (미존재 브랜드, 삭제된 브랜드) | +| `BrandFacadeTest` | `ProductService` 생성자에서 `brandService` 파라미터 제거 | + +#### 결과 구조 + +``` +AdminProductV1Controller → ProductFacade → BrandService (검증) + → ProductService (저장/조회/수정/삭제) +BrandFacade → ProductService (연쇄 삭제) + → BrandService (브랜드 삭제) +ProductV1Controller → ProductService (사용자 읽기 전용, Facade 불필요) +``` + +- `BrandV1Controller`가 `BrandService` 직접 사용하는 것과 동일한 패턴 유지 +- 전체 테스트 통과 확인 + +--- + +### ProductOrder 도입 — 레이어 의존 방향 정리 (2026-02-24) + +#### 문제 + +`ProductRepository` (domain)가 `ProductSort` (application)를 import하고 있어 `domain → application` 의존 방향 위반. + +```java +// domain/product/ProductRepository.java +import com.loopers.application.product.ProductSort; // ← 위반 +Page findProducts(Long brandId, ProductSort sort, Pageable pageable); +``` + +#### 해결 방향 토론 + +`ProductSort`를 domain으로 옮기는 안도 검토했으나, **정렬은 조회 정책이지 도메인 불변 규칙이 아니다**. 따라서 개념적으로 domain 소속이 어색하다. + +대신 두 타입의 역할을 명확히 분리했다: + +| 타입 | 위치 | 역할 | +|------|------|------| +| `ProductSort` | `application/product/` | API 파라미터 (`@RequestParam`) — 외부 노출용 | +| `ProductOrder` | `domain/product/` | Repository 계약용 — domain 내부 정렬 기준 | + +#### 구현 + +- `ProductOrder` 신설 (`domain/product/`) +- `ProductSort.toOrder()` 추가 — application → domain 방향 변환 (의존 방향 정상) +- `ProductRepository` / `ProductRepositoryImpl`: `ProductOrder` 사용 +- `ProductService`: `sort.toOrder()`로 변환해 repository에 전달 +- `ProductV1Controller`: `ProductSort` 유지 (API 파라미터 역할 명확) + +#### 의존 흐름 + +``` +ProductV1Controller(interfaces) → ProductSort(application) +ProductSort.toOrder() → ProductOrder(domain) [application → domain, 정상] +ProductRepository(domain) ← ProductOrder(domain) [domain 내부] +ProductRepositoryImpl(infra) → ProductOrder(domain) [infra → domain, 정상] +``` + +--- + +### 네이밍 정리 — Admin 네이밍 제거 및 정직화 (2026-02-24) + +#### 문제 + +1. `findProducts`: 메서드명만 봐서는 어떤 필터가 적용되는지 알 수 없음 +2. `findAllProducts`: "All"이라는 이름인데 `deletedAt.isNull()` 필터가 있어 실제로는 전부 조회가 아님 +3. `getAdminProducts`: 서비스 메서드명에 actor("Admin")가 노출됨 — 서비스가 호출자를 인식하는 구조 + +#### 변경 내용 + +| 변경 전 | 변경 후 | 레이어 | +|---------|---------|--------| +| `findProducts` | `findVisibleProducts` | Repository (domain, infra, InMemory) | +| `findAllProducts` `deletedAt.isNull()` 조건 | 조건 제거 — 진짜 전체 조회 | RepositoryImpl, InMemoryProductRepository | +| `getProducts` | `getVisibleProducts` | ProductService | +| `getAdminProducts` | `getAllProducts` | ProductService, ProductFacade | + +#### 결과 + +- 메서드명만 보고 동작을 예측할 수 있게 됨 +- 서비스가 actor를 모름 — "visible한 상품 조회" vs "전체 상품 조회"로 표현 +- 어드민 API는 `getAllProducts` 호출, 사용자 API는 `getVisibleProducts` 호출 — 어떤 조건인지 호출부에서 결정 + +--- + +### 테스트 변수 네이밍 원칙 + +단언에 사용되는 값은 모두 변수로 추출해 가독성 확보. 하드코딩 직접 비교 지양. + +```java +// ✅ +String brandName = "나이키"; +Brand brand = brandService.create(brandName, "설명"); +assertThat(response.getBody().data().name()).isEqualTo(brandName); + +// ❌ +assertThat(response.getBody().data().name()).isEqualTo("나이키"); +``` + +--- + +--- + +## 5. 인증 — HandlerInterceptor + ArgumentResolver (2026-02-25) + +### 배경 (보안 취약점 + 인증 책임 분산) + +**문제 1 — IDOR (Insecure Direct Object Reference)** +`UserV1Controller.getMyInfo`가 `X-Loopers-LoginId` 헤더만으로 타인의 정보를 조회할 수 있었음. +비밀번호 검증 없이 loginId만 알면 누구든 조회 가능한 상태였음. + +**문제 2 — 인증 로직 분산** +`LikeV1Controller` 3개 메서드 모두 `userService.getUserId(loginId, loginPw)` 반복 호출. +Controller가 `UserService`를 직접 의존해 인증 책임이 산재. + +### 구현 내용 + +#### 신규 파일 + +| 파일 | 패키지 | 설명 | +|------|--------|------| +| `LoginUser.java` | `interfaces/api/auth/` | `@Target(PARAMETER)` 커스텀 어노테이션 | +| `LoginUserInterceptor.java` | `interfaces/api/auth/` | `preHandle`에서 헤더 검증 + 인증 처리 | +| `LoginUserArgumentResolver.java` | `interfaces/api/auth/` | `@LoginUser Long userId` 파라미터 주입 | +| `WebConfig.java` | `interfaces/config/` | `WebMvcConfigurer` 구현 — 인터셉터/리졸버 등록 | + +#### 기존 파일 수정 + +| 파일 | 변경 내용 | +|------|----------| +| `UserRepository.java` | `findById(Long id)` 추가 | +| `UserRepositoryImpl.java` | `findById` 구현 → `userJpaRepository.findById(id)` 위임 | +| `UserService.java` | `getUserId(loginId, loginPw): Long` → `authenticate(loginId, loginPw): User`, `getMyInfo(String loginId)` → `getMyInfo(Long userId)`, `getUserById(Long)` 추가 | +| `UpdatePasswordCommand.java` | `String loginId` → `Long userId` | +| `UserV1Controller.java` | `@RequestHeader loginId` 제거, `@LoginUser Long userId` 추가 | +| `LikeV1Controller.java` | `UserService` 의존 제거, 3개 메서드 모두 `@LoginUser Long userId`로 교체 | + +#### 테스트 수정 + +| 파일 | 변경 내용 | +|------|----------| +| `InMemoryUserRepository.java` | reflection으로 ID 할당 + `findById` 구현 추가 (InMemoryBrandRepository와 동일 패턴) | +| `UserServiceTest.java` | `getMyInfo(user.getLoginId())` → `getMyInfo(user.getId())`, UpdatePasswordCommand에서 `loginId` → `userId` | +| `UserServiceIntegrationTest.java` | `userJpaRepository.save()` 반환값 캡처 → `user.getId()` 사용 | +| `UserV1ApiE2ETest.java` | `GetMyInfo.returnsOk_whenUserExists`: BCrypt 인코딩 password + `X-Loopers-LoginPw` 헤더 추가 | +| `UserV1ApiE2ETest.java` | `returnsBadRequest_whenCurrentPasswordNotMatches`: 기댓값 BAD_REQUEST → NOT_FOUND | + +### 인터셉터 동작 흐름 + +``` +preHandle + 1. X-Loopers-LoginId, X-Loopers-LoginPw 헤더 확인 + 2. 하나라도 없으면 CoreException(NOT_FOUND) throw + 3. userService.authenticate(loginId, loginPw) → 비밀번호 불일치 시 NOT_FOUND + 4. request.setAttribute("userId", user.getId()) + +LoginUserArgumentResolver + 5. @LoginUser + Long 타입 파라미터 감지 + 6. request.getAttribute("userId") 반환 +``` + +### 인터셉터 적용 경로 — opt-out 패턴 + +```java +// WebConfig.java +.addPathPatterns("/api/v1/**") +.excludePathPatterns( + "/api/v1/users", // POST: 회원가입 + "/api/v1/brands/**", // GET: 브랜드 조회 + "/api/v1/products", // GET: 상품 목록 + "/api/v1/products/*", // GET: 상품 상세 (* 는 '/' 미포함 → /likes 는 인터셉터 적용) + "/api/v1/examples/**" // GET: 예시 +) +``` + +- **secure by default** 원칙: 새 API 추가 시 기본으로 인증이 적용되고, 공개 엔드포인트만 명시적으로 제외 +- 어드민(`/api-admin/**`)은 경로 자체가 달라서 이 인터셉터와 무관 +- `X-Loopers-Ldap` 헤더 기반 어드민 인증은 각 Controller의 `@RequestHeader`로만 처리 (값 검증 없는 placeholder) + +### authenticate() 반환 타입 — User vs Long + +기존 `getUserId()` → `Long` 반환: userId만 필요하므로 충분했으나, 이름에서 "인증"의 의미가 드러나지 않음. +변경 후 `authenticate()` → `User` 반환: 메서드명이 인증 의도를 명확히 표현, 인터셉터에서 `user.getId()`로 userId 추출. + +### updatePassword — 이중 비밀번호 검증 + +인터셉터: `X-Loopers-LoginPw`로 1차 인증 통과 → 서비스 레이어: `currentPassword` 헤더로 2차 검증. +1차가 통과된 시점에서 2차는 항상 통과하지만, 서비스 레이어의 비즈니스 검증을 Controller가 bypass할 수 없도록 서비스 레이어 검증을 유지. + +--- + +--- + +## 6. LikeCount 구현 (2026-02-25) + +### 구현 내용 + +`Product.likeCount` 카운터 컬럼 방식으로 구현. TDD(Red → Green) 순서 준수. + +#### 수정 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `Product.java` | `likeCount` 필드 추가 (기본값 0), `incrementLikeCount()`, `decrementLikeCount()` 메서드 추가 | +| `ProductInfo.java` | `likeCount` 필드 추가 (stockQuantity와 visibility 사이), `from()` 갱신 | +| `ProductService.java` | `incrementLikeCount(Long productId)`, `decrementLikeCount(Long productId)` 추가 | +| `LikeService.java` | `cancel()` 반환 타입 `void` → `boolean` (실제 삭제 여부 반환) | +| `LikeFacade.java` | `register()`: 좋아요 저장 후 `productService.incrementLikeCount()` 호출, `cancel()`: 실제 삭제된 경우에만 `productService.decrementLikeCount()` 호출 | +| `ProductV1Dto.java` | `ProductResponse`, `AdminProductResponse`에 `likeCount` 추가 | + +#### 테스트 추가 + +| 파일 | 추가 테스트 | +|------|------------| +| `ProductTest.java` | `LikeCount` Nested — 기본값 0, increment +1, decrement -1, 0 이하 방지 (4건) | +| `LikeFacadeTest.java` | `Register` Nested — 등록 시 likeCount +1, 중복 시 예외 (2건), `Cancel` Nested — 있을 때 -1 / 없을 때 불변 (2건) | +| `LikeServiceTest.java` | `Cancel` Nested — 존재 시 true 반환 / 미존재 시 false 반환 (2건) | +| `ProductV1ApiE2ETest.java` | 상품 조회 시 `likeCount=0` 반환 확인 (1건) | + +#### 설계 결정 + +**LikeService.cancel() — DELETE row count 방식 (정확한 카운터)** +- SELECT 없이 `deleteByUserIdAndProductId()` 반환값(삭제된 row 수)으로 존재 여부 판별 +- 삭제 성공 시 `true`, 미존재 시 `false` 반환 +- LikeFacade에서 `wasLiked` 체크 후 조건부 감산 → 멱등 취소에서 카운터 오차 없음 +- SELECT → DELETE 패턴 대비 DB 쿼리 1회 절감, TOCTOU 레이스 컨디션 없음 + +**LikeFacade.register() — `ensureActiveProduct` 호출** +- 기존 `getVisibleProduct()` 반환값을 버리는 구조에서 `ensureActiveProduct()`로 교체 +- 의도 명확화: 존재/노출 여부를 보장하는 precondition 체크임을 이름으로 표현 + +**브랜드 삭제 시 likeCount 감산 불필요** +- `BrandFacade.delete()` 시 상품도 soft delete됨 → likeCount 값 자체가 의미 없어짐 +- 별도 감산 처리 추가하지 않음 + +--- + +## 7. Order 도메인 구현 (2026-02-26) + +### 구현 태스크 (완료) + +- [x] Order 엔티티 + Order.Status inner enum +- [x] OrderItem 엔티티 (스냅샷: productName, price) +- [x] OrderItemSnapshot record — Facade에서 Order/OrderItem 생성 시 중간 매개체 +- [x] OrderRepository, OrderItemRepository 인터페이스 + JPA 구현체 +- [x] OrderService (주문 항목 검증, 주문 저장/조회) +- [x] OrderFacade (상품 조회 → 재고 차감 → 주문 저장 조율) +- [x] 사용자 API (OrderV1Controller, OrderV1Dto) +- [x] 어드민 API (AdminOrderV1Controller) +- [x] 단위 테스트 (OrderTest, OrderItemTest, OrderServiceTest, OrderFacadeTest) +- [x] E2E 테스트 (OrderV1ApiE2ETest, AdminOrderV1ApiE2ETest) +- [ ] `http/commerce-api/order-v1.http` 파일 작성 ← **미작성** + +### 재고 차감 방식 — DB 레벨 원자적 처리 + +초기 설계(시퀀스 다이어그램)에서는 "메모리에서 재고 검증 → saveAll"을 상정했으나 구현 시 DB 레벨 원자적 차감으로 변경. + +```sql +-- ProductJpaRepository.decreaseStockIfEnough (JPQL @Modifying) +update Product p + set p.stockQuantity = p.stockQuantity - :quantity + where p.id = :productId + and p.deletedAt is null + and p.visibility = Product.Visibility.VISIBLE + and p.stockQuantity >= :quantity +``` + +- 조건 불충족(재고 부족, HIDDEN, 삭제) 시 UPDATE 0건 → `false` 반환 → `INSUFFICIENT_STOCK` 예외 +- productId 오름차순 정렬 후 차감 — 데드락 방지 (다중 상품 주문 시 동일 순서로 락 획득) +- InMemoryProductRepository의 `decreaseStockIfEnough`는 인터페이스 계약 충족용 stub (재고 차감 없음). 단위 테스트에서 재고 차감 동작은 검증하지 않고 E2E에서 커버. + +### 주문 생성 최종 흐름 + +``` +OrderFacade.createOrder(command) + 1. orderService.validateItems(items) — 빈 항목, 수량≤0, 중복 상품 검증 + 2. productService.getActiveProductsByIdsOrThrow(productIds) — HIDDEN/삭제 상품 → NOT_FOUND + 3. productService.decreaseStock(items) — DB 원자적 차감, 재고 부족 → INSUFFICIENT_STOCK + 4. orderService.placeOrder(userId, snapshots) — Order + OrderItem 저장 +``` + +### 비즈니스 규칙 확정 + +- 주문 항목 중 하나라도 재고 부족 → 전체 주문 거부 (부분 성공 없음) +- 상품명·가격 스냅샷 저장 (`OrderItem.productName`, `OrderItem.price`) +- 주문 생성 시 즉시 ORDERED 상태, 상태 전이는 추후 결제 연동 시 확장 +- 같은 상품 중복 주문 항목 불가 (OrderService 검증) +- 주문 수량 1개 이상 (OrderService 검증) +- 타인 주문 접근 시 → 404 (리소스 존재 여부 비노출) + +--- + +## 8. 리팩터링 — Order 구현 전후 (2026-02-27 ~ 2026-03-04) + +### active 네이밍 통합 (2026-02-27) + +`visibility=VISIBLE && deletedAt=null` 조건이 여러 곳에 흩어져 있었음. + +#### 변경 내용 + +| 변경 전 | 변경 후 | +|---------|---------| +| `getVisibleProduct(id)` | `getActiveProduct(id)` | +| `getVisibleProductsByIds(ids)` | `getActiveProductsByIds(ids)` | +| `findVisibleProducts(...)` | `findActiveProducts(...)` | +| 메서드마다 조건 중복 | `Product.isActive()` — visibility==VISIBLE && deletedAt==null 캡슐화 | + +- `Product.isActive()` 도입으로 "활성 상품" 개념을 도메인 객체 내로 응집 + +--- + +### ProductInfo.BrandSummary VO 도입 (2026-02-27) + +#### 문제 + +`ProductInfo`가 `brandId`와 `brandName`을 flat하게 들고 있어, Facade에서 브랜드명을 enrichment할 때 필드가 분산됨. + +#### 변경 내용 + +```java +// 변경 전 +public record ProductInfo(Long id, Long brandId, String brandName, ...) + +// 변경 후 +public record ProductInfo(Long id, BrandSummary brand, ...) { + public record BrandSummary(Long id, String name) {} +} +``` + +- `withBrandName(String)` → `withBrand(BrandSummary)` 메서드 교체 +- 브랜드 관련 데이터를 `BrandSummary`로 캡슐화해 확장성 확보 (브랜드 필드 추가 시 ProductInfo 시그니처 불변) + +--- + +### Order 주문금액 계산 책임 이전 (2026-03-03) + +#### 문제 + +초기 구현에서 `OrderService.placeOrder()`가 `totalAmount`를 파라미터로 받아 저장하는 구조 → 외부에서 계산하고 전달, Order 자신이 책임지지 않음. + +#### 변경 내용 + +```java +// 변경 전: 외부에서 합산 후 전달 +Order.create(userId, totalAmount) + +// 변경 후: Order가 스냅샷 목록을 받아 직접 계산 +Order.create(userId, List snapshots) +private Long calculateTotalAmount(List snapshots) +``` + +- `OrderItemSnapshot.lineAmount()` 추가 (price × quantity) +- Order가 스냅샷 목록을 받아 `totalAmount` 직접 계산 — "내 금액은 내가 계산한다" +- `OrderService`에서 totalAmount 계산 로직 제거 + +--- + +### ApplicationService → Service 네이밍 (2026-03-03) + +#### 배경 + +`BrandApplicationService`, `ProductApplicationService` 등 `ApplicationService` suffix를 사용하다가 `Service`로 단순화. + +- 이미 `application/` 패키지에 위치하므로 이름에서 중복 표현 불필요 +- `BrandApplicationService` → `BrandService`, `ProductApplicationService` → `ProductService` 등 전면 변경 + +--- + +### Facade 단순 위임 메서드 제거 — 오케스트레이션만 담당 (2026-03-03) + +#### 배경 + +`BrandFacade`에 `register`, `getBrand`, `getBrands`, `update` 등 단순히 `BrandService`를 그대로 호출하는 메서드가 생겨났음. `ProductFacade`도 마찬가지로 `getProduct`, `getProducts`, `update` 등이 있었음. + +#### 문제 + +- Facade가 "무슨 일이든 다 거쳐야 하는 단일 진입점"이 되면 오케스트레이션 없는 단순 위임 메서드로 가득 찬 뚱뚱한 클래스가 됨 +- 새로운 기능 추가 시 Facade에 메서드를 무조건 추가해야 한다는 잘못된 관습 형성 + +#### 변경 내용 + +| Facade | 제거된 메서드 | +|--------|-------------| +| `BrandFacade` | `register`, `getBrand`, `getBrands`, `update` | +| `ProductFacade` | `getProduct`, `getProducts`, `update` | + +#### 결과 구조 — Facade 사용 기준 + +| 컨트롤러 | Facade 사용 (오케스트레이션) | Service 직접 사용 (단순 CRUD) | +|----------|--------------------------|---------------------------| +| `AdminBrandV1Controller` | `delete` (연쇄 삭제) | `register`, `getBrand`, `getBrands`, `update` | +| `AdminProductV1Controller` | `register` (브랜드 검증), `delete` (좋아요 연쇄) | `getProduct`, `getProducts`, `update` | +| `ProductV1Controller` | `getActiveProduct`, `getActiveProducts` (브랜드명 enrichment) | — | + +**원칙**: 두 개 이상의 Service를 조율하거나 복수 도메인에 걸친 트랜잭션이 필요한 경우에만 Facade를 경유. 단일 Service 위임은 Controller에서 직접 호출. + +--- + +### OrderCreateCommand interfaces 계층 의존 제거 (2026-03-03) + +#### 문제 + +```java +// application/order/OrderCreateCommand.java +public static OrderCreateCommand from(Long userId, OrderV1Dto.CreateRequest request) { ... } +// ↑ interfaces 계층 타입 — 방향 위반 +``` + +`application` 계층의 Command가 `interfaces` 계층의 DTO를 알고 있는 구조. + +#### 변경 내용 + +- `OrderCreateCommand.from(userId, request)` 팩토리 메서드 제거 +- 매핑 책임을 `OrderV1Controller`로 이동: Controller에서 DTO → Command 변환 후 Facade 호출 + +```java +// interfaces 계층 (Controller) — 매핑 책임 +List items = request.items().stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); +orderFacade.createOrder(new OrderCreateCommand(userId, items)); +``` + +**원칙 재확인**: `Domain → Application → Interface` 의존 방향. Command/Info 객체는 interfaces 계층을 몰라야 한다. + +--- + +### Product.decreaseStock 0/음수 수량 guard 추가 (2026-03-03) + +```java +public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + ... +} +``` + +- OrderService의 `validateItems`에서도 `quantity <= 0` 검증을 하지만, 도메인 객체 스스로 불변식을 지키는 것이 올바름 +- 방어 계층 이중화: Service 검증이 빠지더라도 엔티티 레벨에서 차단 + +--- + +### decreaseStockIfEnough — visibility 파라미터 제거 (2026-03-04) + +#### 문제 + +```java +// 변경 전 +int decreaseStockIfEnough(Long productId, Integer quantity, Product.Visibility visibility); +// 호출부 +productJpaRepository.decreaseStockIfEnough(productId, quantity, Product.Visibility.VISIBLE); +``` + +visibility를 외부에서 파라미터로 넘기는 구조 → "항상 VISIBLE 상품만 차감"이라는 정책이 호출부에 노출됨. + +#### 변경 내용 + +JPQL에 `and p.visibility = Product.Visibility.VISIBLE` 하드코딩, 파라미터 제거. + +- "재고 차감은 VISIBLE 상품에 대해서만 가능하다"는 정책이 쿼리 내부로 응집 +- 호출부에서 visibility 파라미터를 잘못 전달할 여지 제거 + +--- + +### @Transactional 클래스 레벨 → 메서드 레벨 이동 (2026-03-03) + +클래스에 `@Transactional`을 선언하면 `readOnly` 여부와 무관하게 모든 메서드에 동일 트랜잭션이 적용되어 의도를 파악하기 어려움. + +- 조회 메서드: `@Transactional(readOnly = true)` 명시 → DB 최적화 힌트 + 의도 명확화 +- 변경 메서드: `@Transactional` 명시 → 트랜잭션 경계를 메서드 단위로 직접 표현 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..4b3e842d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,25 @@ +package com.loopers.application.brand; + +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class BrandFacade { + private final BrandService brandService; + private final ProductService productService; + private final LikeService likeService; + + @Transactional + public void delete(Long brandId) { + List productIds = productService.getProductIdsByBrandId(brandId); + likeService.deleteAllByProductIds(productIds); + productService.deleteAllByBrandId(brandId); + brandService.delete(brandId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..0c19ef222 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.ZonedDateTime; + +public record BrandInfo( + Long id, + String name, + String description, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java new file mode 100644 index 000000000..c74d9db06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -0,0 +1,74 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class BrandService { + private final BrandRepository brandRepository; + + @Transactional + public BrandInfo register(String name, String description) { + Brand brand = Brand.create(name, description); + return BrandInfo.from(brandRepository.save(brand)); + } + + @Transactional(readOnly = true) + public BrandInfo getBrand(Long id) { + Brand brand = findById(id); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "[brandId = " + id + "] 를 찾을 수 없습니다."); + } + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public Page getBrands(Pageable pageable) { + return brandRepository.findAllByDeletedAtIsNull(pageable).map(BrandInfo::from); + } + + @Transactional + public BrandInfo update(Long id, String name, String description) { + Brand brand = findNonDeletedById(id); + brand.update(name, description); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public Map getBrandNameMap(Collection brandIds) { + return brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Brand::getName)); + } + + @Transactional + public void delete(Long id) { + Brand brand = findById(id); + brand.delete(); + } + + private Brand findNonDeletedById(Long id) { + Brand brand = findById(id); + if (brand.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "[brandId = " + id + "] 를 찾을 수 없습니다."); + } + return brand; + } + + private Brand findById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[brandId = " + id + "] 를 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..09570baba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,47 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class LikeFacade { + private final LikeService likeService; + private final ProductService productService; + + @Transactional + public LikeInfo register(Long userId, Long productId) { + LikeInfo like = likeService.register(userId, productId); + productService.increaseLikeCount(productId); + return like; + } + + @Transactional + public void cancel(Long userId, Long productId) { + if (likeService.cancel(userId, productId)) { + productService.decreaseLikeCount(productId); + } + } + + public List getLikedProductsByUserId(Long userId) { + List likes = likeService.getLikesByUserId(userId); + if (likes.isEmpty()) return List.of(); + + List productIds = likes.stream().map(LikeInfo::productId).toList(); + Map productMap = productService.getActiveProductsByIds(productIds) + .stream() + .collect(Collectors.toMap(ProductInfo::id, p -> p)); + + return likes.stream() + .filter(like -> productMap.containsKey(like.productId())) + .map(like -> new LikedProductInfo(like.id(), productMap.get(like.productId()), like.createdAt())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..59d31bd34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long id, + Long userId, + Long productId, + ZonedDateTime createdAt +) { + public static LikeInfo from(Like like) { + return new LikeInfo( + like.getId(), + like.getUserId(), + like.getProductId(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..9d1fcd2ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,49 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class LikeService { + private final LikeRepository likeRepository; + + @Transactional + public LikeInfo register(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(like -> { + throw new CoreException(ErrorType.ALREADY_LIKED, "이미 좋아요한 상품입니다."); + }); + + Like like = Like.create(userId, productId); + return LikeInfo.from(likeRepository.save(like)); + } + + @Transactional + public boolean cancel(Long userId, Long productId) { + return likeRepository.deleteByUserIdAndProductId(userId, productId) > 0; + } + + @Transactional(readOnly = true) + public List getLikesByUserId(Long userId) { + return likeRepository.findByUserId(userId) + .stream() + .map(LikeInfo::from) + .toList(); + } + + @Transactional + public void deleteAllByProductIds(List productIds) { + if (productIds.isEmpty()) { + return; + } + + likeRepository.deleteAllByProductIdIn(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java new file mode 100644 index 000000000..bab113bbc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductInfo; + +import java.time.ZonedDateTime; + +public record LikedProductInfo( + Long likeId, + ProductInfo product, + ZonedDateTime likedAt +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java new file mode 100644 index 000000000..eebef7dd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.order; + +import java.util.List; + +public record OrderCreateCommand(Long userId, List items) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..28fc24a37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,39 @@ +package com.loopers.application.order; + +import com.loopers.application.product.ProductService; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.order.OrderItemSnapshot; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class OrderFacade { + private final OrderService orderService; + private final ProductService productService; + + @Transactional + public OrderInfo createOrder(OrderCreateCommand command) { + orderService.validateItems(command.items()); + List productIds = command.items().stream() + .map(OrderItemCommand::productId) + .toList(); + List products = productService.getActiveProductsByIdsOrThrow(productIds); + productService.decreaseStock(command.items()); + + Map productMap = products.stream() + .collect(Collectors.toMap(ProductInfo::id, p -> p)); + List snapshots = command.items().stream() + .map(item -> { + ProductInfo p = productMap.get(item.productId()); + return new OrderItemSnapshot(p.id(), p.name(), p.price(), item.quantity()); + }) + .toList(); + return orderService.placeOrder(command.userId(), snapshots); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..a2d532ad2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; + +import java.time.ZonedDateTime; + +public record OrderInfo( + Long id, + Long userId, + Order.Status status, + Long totalAmount, + ZonedDateTime createdAt +) { + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getStatus(), + order.getTotalAmount(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..2e4a3bbea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,3 @@ +package com.loopers.application.order; + +public record OrderItemCommand(Long productId, Integer quantity) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..7af66b648 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long id, + Long orderId, + Long productId, + String productName, + Long price, + Integer quantity +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getOrderId(), + item.getProductId(), + item.getProductName(), + item.getUnitPrice(), + item.getQuantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..5f5744f5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,93 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class OrderService { + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + public void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + + boolean hasInvalidQuantity = items.stream().anyMatch(item -> item.quantity() <= 0); + if (hasInvalidQuantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + + long distinctCount = items.stream().map(OrderItemCommand::productId).distinct().count(); + if (distinctCount != items.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); + } + } + + @Transactional + public OrderInfo placeOrder(Long userId, List snapshots) { + Order order = orderRepository.save(Order.create(userId, snapshots)); + + List orderItems = snapshots.stream() + .map(s -> OrderItem.create( + order.getId(), + s.productId(), + s.productName(), + s.unitPrice(), + s.quantity() + )) + .toList(); + orderItemRepository.saveAll(orderItems); + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public List getOrders(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findByUserIdAndCreatedAtBetween(userId, startAt, endAt) + .stream() + .map(OrderInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findByOrderId(orderId) + .stream() + .map(OrderItemInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public Page getAllOrders(Pageable pageable) { + return orderRepository.findAll(pageable).map(OrderInfo::from); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderById(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java new file mode 100644 index 000000000..1b362e588 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCreateCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +public record ProductCreateCommand( + Long brandId, + String name, + String description, + Integer price, + Integer stockQuantity +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..ae705bb43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,50 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +public class ProductFacade { + private final BrandService brandService; + private final ProductService productService; + private final LikeService likeService; + + @Transactional + public ProductInfo register(ProductCreateCommand command) { + brandService.getBrand(command.brandId()); + return productService.register(command); + } + + public ProductInfo getActiveProduct(Long id) { + ProductInfo product = productService.getActiveProduct(id); + String brandName = brandService.getBrandNameMap(List.of(product.brand().id())) + .get(product.brand().id()); + return product.withBrand(new ProductInfo.BrandSummary(product.brand().id(), brandName)); + } + + public Page getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { + Page products = productService.getActiveProducts(brandId, sort, pageable); + Set brandIds = products.stream().map(p -> p.brand().id()).collect(Collectors.toSet()); + Map brandNameMap = brandService.getBrandNameMap(brandIds); + return products.map(p -> p.withBrand( + new ProductInfo.BrandSummary(p.brand().id(), brandNameMap.getOrDefault(p.brand().id(), null)) + )); + } + + @Transactional + public void delete(Long id) { + likeService.deleteAllByProductIds(List.of(id)); + productService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..49c5d8496 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,45 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductInfo( + Long id, + BrandSummary brand, + String name, + String description, + Integer price, + Integer stockQuantity, + Integer likeCount, + Product.Visibility visibility, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + public record BrandSummary(Long id, String name) {} + + public static ProductInfo from(Product product) { + return new ProductInfo( + product.getId(), + new BrandSummary(product.getBrandId(), null), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStockQuantity(), + product.getLikeCount(), + product.getVisibility(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } + + public ProductInfo withBrand(BrandSummary brand) { + return new ProductInfo( + id, brand, name, description, + price, stockQuantity, likeCount, visibility, + createdAt, updatedAt, deletedAt + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..7488625b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,153 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class ProductService { + private final ProductRepository productRepository; + + @Transactional + public ProductInfo register(ProductCreateCommand command) { + Product product = Product.create(command.brandId(), command.name(), command.description(), command.price(), command.stockQuantity()); + return ProductInfo.from(productRepository.save(product)); + } + + @Transactional(readOnly = true) + public ProductInfo getActiveProduct(Long id) { + Product product = findById(id); + if (!product.isActive()) { + throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + id + "] 를 찾을 수 없습니다."); + } + + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public ProductInfo getProduct(Long id) { + Product product = findById(id); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + id + "] 를 찾을 수 없습니다."); + } + + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public Page getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { + return productRepository.findActiveProducts(brandId, sort.toOrder(), pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, Pageable pageable) { + return productRepository.findAllProducts(brandId, pageable).map(ProductInfo::from); + } + + @Transactional + public ProductInfo update(Long id, ProductUpdateCommand command) { + Product product = findById(id); + if (product.getDeletedAt() != null) { + throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + id + "] 를 찾을 수 없습니다."); + } + + product.update(command.name(), command.description(), command.price(), command.stockQuantity()); + if (command.visibility() != null) { + product.changeVisibility(command.visibility()); + } + + return ProductInfo.from(product); + } + + @Transactional + public void delete(Long id) { + Product product = findById(id); + product.delete(); + } + + @Transactional(readOnly = true) + public List getProductIdsByBrandId(Long brandId) { + return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId) + .stream() + .map(Product::getId) + .toList(); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + products.forEach(Product::delete); + } + + @Transactional + public void increaseLikeCount(Long productId) { + Product product = findById(productId); + if (!product.isActive()) { + throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); + } + + product.increaseLikeCount(); + } + + @Transactional + public void decreaseLikeCount(Long productId) { + Product product = findById(productId); + if (!product.isActive()) { + throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); + } + + product.decreaseLikeCount(); + } + + @Transactional + public void decreaseStock(List items) { + List sorted = items.stream() + .sorted(Comparator.comparing(OrderItemCommand::productId)) + .toList(); + + for (OrderItemCommand item : sorted) { + boolean decreased = productRepository.decreaseStockIfEnough(item.productId(), item.quantity()); + if (!decreased) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "재고가 부족한 상품이 있습니다."); + } + } + } + + @Transactional(readOnly = true) + public List getActiveProductsByIds(List ids) { + return productRepository.findAllByIdInAndDeletedAtIsNull(ids) + .stream() + .filter(Product::isActive) + .map(ProductInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getActiveProductsByIdsOrThrow(List ids) { + List distinctIds = ids.stream().distinct().toList(); + List products = getActiveProductsByIds(distinctIds); + + if (products.size() != distinctIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "주문 상품이 올바르지 않습니다."); + } + + return products; + } + + private Product findById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[productId = " + id + "] 를 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSort.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSort.java new file mode 100644 index 000000000..627145b25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSort.java @@ -0,0 +1,15 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductOrder; + +public enum ProductSort { + LATEST, PRICE_ASC, LIKES_DESC; + + public ProductOrder toOrder() { + return switch (this) { + case LATEST -> ProductOrder.LATEST; + case PRICE_ASC -> ProductOrder.PRICE_ASC; + case LIKES_DESC -> ProductOrder.LIKES_DESC; + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java new file mode 100644 index 000000000..a8910d83b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductUpdateCommand.java @@ -0,0 +1,11 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +public record ProductUpdateCommand( + String name, + String description, + Integer price, + Integer stockQuantity, + Product.Visibility visibility +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpService.java new file mode 100644 index 000000000..e8f242d7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/SignUpService.java @@ -0,0 +1,28 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SignUpService { + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + + @Transactional + public void signUp(SignUpCommand command) { + if (userRepository.findByLoginId(command.loginId()).isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + PasswordPolicyValidator.validate(command.password(), command.birthDate()); + String encodedPassword = passwordEncoder.encode(command.password()); + User user = User.create(command, encodedPassword); + + userRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java index ebf9f0f50..5b0a45dcc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UpdatePasswordCommand.java @@ -3,11 +3,11 @@ import com.loopers.interfaces.api.user.dto.UserV1Dto; public record UpdatePasswordCommand( - String loginId, + Long userId, String currentPassword, String newPassword ) { - public static UpdatePasswordCommand from(String loginId, String currentPassword, UserV1Dto.UpdatePasswordRequest request) { - return new UpdatePasswordCommand(loginId, currentPassword, request.newPassword()); + public static UpdatePasswordCommand from(Long userId, String currentPassword, UserV1Dto.UpdatePasswordRequest request) { + return new UpdatePasswordCommand(userId, currentPassword, request.newPassword()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java similarity index 57% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 160d6a905..9fc9e28d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -1,7 +1,9 @@ -package com.loopers.domain.user; +package com.loopers.application.user; -import com.loopers.application.user.UpdatePasswordCommand; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.user.PasswordPolicyValidator; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -16,7 +18,7 @@ public class UserService { @Transactional public void updatePassword(UpdatePasswordCommand command) { - User user = getUser(command.loginId()); + User user = getUserById(command.userId()); if (!passwordEncoder.matches(command.currentPassword(), user.getPassword())) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); @@ -32,14 +34,30 @@ public void updatePassword(UpdatePasswordCommand command) { } @Transactional(readOnly = true) - public UserInfo getMyInfo(String loginId) { - User user = getUser(loginId); + public UserInfo getMyInfo(Long userId) { + User user = getUserById(userId); return UserInfo.from(user); } - private User getUser(String loginId) { + @Transactional(readOnly = true) + public User authenticate(String loginId, String loginPw) { + User user = getUserByLoginId(loginId); + if (!passwordEncoder.matches(loginPw, user.getPassword())) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자 정보가 올바르지 않습니다."); + } + + return user; + } + + private User getUserByLoginId(String loginId) { return userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] 를 찾을 수 없습니다.")); } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + "[userId = " + userId + "] 를 찾을 수 없습니다.")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..e0b2746d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,42 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "brands") +public class Brand extends BaseEntity { + @Column(nullable = false) + private String name; + private String description; + + protected Brand() {} + + private Brand(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + public static Brand create(String name, String description) { + return new Brand(name, description); + } + + public void update(String name, String description) { + validateName(name); + this.name = name; + this.description = description; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수값입니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..86719f9f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByIdIn(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..77e63bc98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"})) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Like() {} + + private Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + public static Like create(Long userId, Long productId) { + return new Like(userId, productId); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..ab9cda82f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + Like save(Like like); + Optional findByUserIdAndProductId(Long userId, Long productId); + int deleteByUserIdAndProductId(Long userId, Long productId); + void deleteAllByProductIdIn(List productIds); + List findByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..b0b8ac8b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,53 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.util.List; + +@Getter +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column(nullable = false) + private Long totalAmount; + + protected Order() {} + + private Order(Long userId, List orderItemSnapshots) { + this.userId = userId; + this.status = Status.ORDERED; + this.totalAmount = calculateTotalAmount(orderItemSnapshots); + } + + private Long calculateTotalAmount( List orderItemSnapshots) { + return orderItemSnapshots.stream() + .mapToLong(OrderItemSnapshot::lineAmount) + .sum(); + } + public static Order create(Long userId, List orderItemSnapshots) { + if (orderItemSnapshots == null || orderItemSnapshots.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + return new Order(userId, orderItemSnapshots); + } + + public enum Status { + ORDERED + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..7f547caa7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,51 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "order_items") +public class OrderItem extends BaseEntity { + + @Column(nullable = false) + private Long orderId; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private String productName; + + @Column(nullable = false) + private long unitPrice; + + @Column(nullable = false) + private int quantity; + + protected OrderItem() {} + + private OrderItem(Long orderId, Long productId, String productName, long unitPrice, Integer quantity) { + validateQuantity(quantity); + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.unitPrice = unitPrice; + this.quantity = quantity; + } + + public static OrderItem create(Long orderId, Long productId, String productName, long price, Integer quantity) { + return new OrderItem(orderId, productId, productName, price, quantity); + } + + private void validateQuantity(Integer quantity) { + if (quantity == null || quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..215c01cf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + OrderItem save(OrderItem orderItem); + List saveAll(List orderItems); + List findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java new file mode 100644 index 000000000..ed03b596e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSnapshot.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +public record OrderItemSnapshot( + Long productId, + String productName, + long unitPrice, + int quantity +) { + public long lineAmount() { + return (long) unitPrice * quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..c350ddcd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + Optional findById(Long id); + List findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..76d605bbf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,113 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "products") +public class Product extends BaseEntity { + + @Column(nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private Integer price; + + @Column(nullable = false) + private Integer stockQuantity; + + @Column(nullable = false) + private Integer likeCount = 0; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Visibility visibility; + + protected Product() {} + + private Product(Long brandId, String name, String description, Integer price, Integer stockQuantity) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stockQuantity = stockQuantity; + this.visibility = Visibility.VISIBLE; + } + + public static Product create(Long brandId, String name, String description, Integer price, Integer stockQuantity) { + return new Product(brandId, name, description, price, stockQuantity); + } + + public void update(String name, String description, Integer price, Integer stockQuantity) { + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + this.name = name; + this.description = description; + this.price = price; + this.stockQuantity = stockQuantity; + } + + public boolean isActive() { + return this.visibility == Visibility.VISIBLE && getDeletedAt() == null; + } + + public void changeVisibility(Visibility visibility) { + this.visibility = visibility; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수값입니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수값입니다."); + } + } + + private void validatePrice(Integer price) { + if (price == null || price <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다."); + } + } + + private void validateStockQuantity(Integer stockQuantity) { + if (stockQuantity == null || stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 0 이상이어야 합니다."); + } + } + + public enum Visibility { + VISIBLE, HIDDEN + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOrder.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOrder.java new file mode 100644 index 000000000..05e5b2c9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOrder.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public enum ProductOrder { + LATEST, PRICE_ASC, LIKES_DESC +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..cbd767e60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + Page findActiveProducts(Long brandId, ProductOrder order, Pageable pageable); + Page findAllProducts(Long brandId, Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + List findAllByIdInAndDeletedAtIsNull(List ids); + boolean decreaseStockIfEnough(Long productId, Integer quantity); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java deleted file mode 100644 index a24218e10..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.SignUpCommand; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@Transactional -@RequiredArgsConstructor -public class SignUpService { - private final SignUpValidator signUpValidator; - private final PasswordEncoder passwordEncoder; - private final UserRepository userRepository; - - public void signUp(SignUpCommand command) { - signUpValidator.validate(command); - String encodedPassword = passwordEncoder.encode(command.password()); - User user = User.create(command, encodedPassword); - - userRepository.save(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java deleted file mode 100644 index 38c4d2a3c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/SignUpValidator.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.SignUpCommand; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.time.LocalDate; - -@Component -@RequiredArgsConstructor -public class SignUpValidator { - private final UserRepository userRepository; - - public void validate(SignUpCommand command) { - if (userRepository.findByLoginId(command.loginId()).isPresent()) { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); - } - - if (command.birthDate().isAfter(LocalDate.now())) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 없습니다."); - } - - PasswordPolicyValidator.validate(command.password(), command.birthDate()); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 7e57a997d..5ab9d9d48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -44,7 +44,6 @@ public static User create(String loginId, String encodedPassword, String name, L public static User create(SignUpCommand command, String encodedPassword) { return new User(command.loginId(), encodedPassword, command.name(), command.birthDate(), command.email()); - } private void validateLoginId(String loginId) { @@ -77,6 +76,10 @@ private void validateBirthDate(LocalDate birthDate) { if (birthDate == null) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수값입니다."); } + + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 없습니다."); + } } private void validateEmail(String email) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index d33a021a1..19a07aa66 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -5,4 +5,5 @@ public interface UserRepository { void save(User user); Optional findByLoginId(String loginId); + Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..eedcd0731 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByIdIn(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..069c9a75b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + return brandJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAllByIdIn(Collection ids) { + return brandJpaRepository.findAllByIdIn(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..61ee2cd28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(Long userId, Long productId); + @Modifying + @Query("DELETE FROM Like l WHERE l.userId = :userId AND l.productId = :productId") + int deleteByUserIdAndProductId(@Param("userId") Long userId, @Param("productId") Long productId); + void deleteAllByProductIdIn(List productIds); + List findByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..38fae4877 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public int deleteByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.deleteByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteAllByProductIdIn(List productIds) { + likeJpaRepository.deleteAllByProductIdIn(productIds); + } + + @Override + public List findByUserId(Long userId) { + return likeJpaRepository.findByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..6add290ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + List findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..f27f11912 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class OrderItemRepositoryImpl implements OrderItemRepository { + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItem save(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } + + @Override + public List findByOrderId(Long orderId) { + return orderItemJpaRepository.findByOrderId(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..9944861e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + List findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..cd8318e38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class OrderRepositoryImpl implements OrderRepository { + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderJpaRepository.findByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..086f17c39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + List findAllByIdInAndDeletedAtIsNull(List ids); + + @Modifying(clearAutomatically = true) + @Query(""" + update Product p + set p.stockQuantity = p.stockQuantity - :quantity + where p.id = :productId + and p.deletedAt is null + and p.visibility = Product.Visibility.VISIBLE + and p.stockQuantity >= :quantity + """) + int decreaseStockIfEnough( + @Param("productId") Long productId, + @Param("quantity") Integer quantity + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..b2f248991 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,112 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductOrder; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.QProduct; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findActiveProducts(Long brandId, ProductOrder order, Pageable pageable) { + QProduct product = QProduct.product; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(product.deletedAt.isNull()); + builder.and(product.visibility.eq(Product.Visibility.VISIBLE)); + + if (brandId != null) { + builder.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = switch (order) { + case PRICE_ASC -> product.price.asc(); + case LIKES_DESC -> product.likeCount.desc(); + case LATEST -> product.id.desc(); + }; + + List content = queryFactory + .selectFrom(product) + .where(builder) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + @Override + public Page findAllProducts(Long brandId, Pageable pageable) { + QProduct product = QProduct.product; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(product.deletedAt.isNull()); + + if (brandId != null) { + builder.and(product.brandId.eq(brandId)); + } + + List content = queryFactory + .selectFrom(product) + .where(builder) + .orderBy(product.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + @Override + public List findAllByBrandIdAndDeletedAtIsNull(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllByIdInAndDeletedAtIsNull(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public boolean decreaseStockIfEnough(Long productId, Integer quantity) { + return productJpaRepository.decreaseStockIfEnough(productId, quantity) > 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index b0f42f5de..67bd70f7d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -22,4 +22,9 @@ public void save(User user) { public Optional findByLoginId(String loginId) { return userJpaRepository.findByLoginId(loginId); } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java new file mode 100644 index 000000000..1b8a87cde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUser.java new file mode 100644 index 000000000..b3a8fdd7d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUser.java @@ -0,0 +1,10 @@ +package com.loopers.interfaces.api.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUser {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserArgumentResolver.java new file mode 100644 index 000000000..ac36f1a6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserArgumentResolver.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(LoginUser.class) + && Long.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + return request.getAttribute("userId"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java new file mode 100644 index 000000000..7011d18e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginUserInterceptor.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.user.UserService; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +@Component +public class LoginUserInterceptor implements HandlerInterceptor { + + private final UserService userService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String loginId = request.getHeader("X-Loopers-LoginId"); + String loginPw = request.getHeader("X-Loopers-LoginPw"); + + if (loginId == null || loginPw == null) { + throw new CoreException(ErrorType.NOT_FOUND, "인증 정보가 없습니다."); + } + + User authenticated = userService.authenticate(loginId, loginPw); + request.setAttribute("userId", authenticated.getId()); + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java new file mode 100644 index 000000000..37215ea99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java @@ -0,0 +1,81 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.dto.BrandV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandV1Controller { + + private final BrandService brandService; + private final BrandFacade brandFacade; + + @GetMapping + public ApiResponse> getBrands( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PageableDefault(size = 20) Pageable pageable + ) { + Page page = brandService.getBrands(pageable) + .map(BrandV1Dto.AdminBrandResponse::from); + return ApiResponse.success(PageResponse.from(page)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId + ) { + BrandInfo brand = brandService.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.AdminBrandResponse.from(brand)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @Valid @RequestBody BrandV1Dto.CreateRequest request + ) { + BrandInfo brand = brandService.register(request.name(), request.description()); + return ApiResponse.success(BrandV1Dto.AdminBrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + public ApiResponse updateBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId, + @Valid @RequestBody BrandV1Dto.UpdateRequest request + ) { + BrandInfo brand = brandService.update(brandId, request.name(), request.description()); + return ApiResponse.success(BrandV1Dto.AdminBrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long brandId + ) { + brandFacade.delete(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..06ca09f8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.brand.dto.BrandV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandService brandService; + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo brand = brandService.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandV1Dto.java new file mode 100644 index 000000000..a896eda29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandV1Dto.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.brand.BrandInfo; +import jakarta.validation.constraints.NotBlank; + +import java.time.ZonedDateTime; + +public class BrandV1Dto { + + public record CreateRequest( + @NotBlank(message = "브랜드 이름은 필수값입니다.") + String name, + String description + ) {} + + public record UpdateRequest( + @NotBlank(message = "브랜드 이름은 필수값입니다.") + String name, + String description + ) {} + + public record BrandResponse(Long id, String name, String description) { + public static BrandResponse from(BrandInfo brandInfo) { + return new BrandResponse(brandInfo.id(), brandInfo.name(), brandInfo.description()); + } + } + + public record AdminBrandResponse(Long id, String name, String description, + ZonedDateTime createdAt, ZonedDateTime updatedAt) { + public static AdminBrandResponse from(BrandInfo brandInfo) { + return new AdminBrandResponse( + brandInfo.id(), brandInfo.name(), brandInfo.description(), + brandInfo.createdAt(), brandInfo.updatedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..a3b833bde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + @LoginUser Long userId, + @PathVariable Long productId + ) { + LikeInfo like = likeFacade.register(userId, productId); + return ApiResponse.success(LikeV1Dto.LikeResponse.from(like)); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + public ApiResponse cancel( + @LoginUser Long userId, + @PathVariable Long productId + ) { + likeFacade.cancel(userId, productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/users/me/likes") + public ApiResponse> getLikes( + @LoginUser Long userId + ) { + List likes = likeFacade.getLikedProductsByUserId(userId).stream() + .map(LikeV1Dto.LikedProductResponse::from) + .toList(); + return ApiResponse.success(likes); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..54c8ea6f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; +import com.loopers.application.like.LikedProductInfo; + +import java.time.ZonedDateTime; + +public class LikeV1Dto { + + public record LikeResponse( + Long id, + Long userId, + Long productId, + ZonedDateTime createdAt + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.id(), + info.userId(), + info.productId(), + info.createdAt() + ); + } + } + + public record LikedProductResponse( + Long likeId, + Long productId, + String productName, + Integer price, + ZonedDateTime likedAt + ) { + public static LikedProductResponse from(LikedProductInfo info) { + return new LikedProductResponse( + info.likeId(), + info.product().id(), + info.product().name(), + info.product().price(), + info.likedAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java new file mode 100644 index 000000000..0edc2d97b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderV1Controller { + + private final OrderService orderService; + + @GetMapping + public ApiResponse> getOrders( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PageableDefault(size = 20) Pageable pageable + ) { + Page page = orderService.getAllOrders(pageable) + .map(OrderV1Dto.OrderResponse::from); + return ApiResponse.success(PageResponse.from(page)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long orderId + ) { + OrderInfo order = orderService.getOrderById(orderId); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order, items)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..9b0612ed0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderService; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + private final OrderService orderService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createOrder( + @LoginUser Long userId, + @Valid @RequestBody OrderV1Dto.CreateRequest request + ) { + List items = request.items().stream() + .map(item -> new OrderItemCommand(item.productId(), item.quantity())) + .toList(); + OrderInfo order = orderFacade.createOrder(new OrderCreateCommand(userId, items)); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(order)); + } + + @GetMapping + public ApiResponse> getOrders( + @LoginUser Long userId, + @RequestParam String startAt, + @RequestParam String endAt + ) { + ZonedDateTime parsedStartAt = parseZonedDateTime(startAt); + ZonedDateTime parsedEndAt = parseZonedDateTime(endAt); + + List orders = orderService.getOrders(userId, parsedStartAt, parsedEndAt) + .stream() + .map(OrderV1Dto.OrderResponse::from) + .toList(); + return ApiResponse.success(orders); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @LoginUser Long userId, + @PathVariable Long orderId + ) { + OrderInfo order = orderService.getOrder(userId, orderId); + List items = orderService.getOrderItems(orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order, items)); + } + + private ZonedDateTime parseZonedDateTime(String value) { + String normalized = value.replace(" ", "+"); + return ZonedDateTime.parse(normalized, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..50916427e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest( + @NotNull @NotEmpty List items + ) {} + + public record OrderItemRequest( + @NotNull Long productId, + @NotNull @Min(1) Integer quantity + ) {} + + public record OrderResponse( + Long id, + Long userId, + String status, + Long totalAmount, + ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.userId(), + info.status().name(), + info.totalAmount(), + info.createdAt() + ); + } + } + + public record OrderDetailResponse( + Long id, + Long userId, + String status, + Long totalAmount, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(OrderInfo order, List items) { + return new OrderDetailResponse( + order.id(), + order.userId(), + order.status().name(), + order.totalAmount(), + order.createdAt(), + items.stream().map(OrderItemResponse::from).toList() + ); + } + } + + public record OrderItemResponse( + Long id, + Long productId, + String productName, + Long price, + Integer quantity + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.id(), + info.productId(), + info.productName(), + info.price(), + info.quantity() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java new file mode 100644 index 000000000..039fe6268 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java @@ -0,0 +1,91 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductService; +import com.loopers.application.product.ProductCreateCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductUpdateCommand; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller { + + private final ProductService productService; + private final ProductFacade productFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @Valid @RequestBody ProductV1Dto.CreateRequest request + ) { + ProductInfo product = productFacade.register( + new ProductCreateCommand(request.brandId(), request.name(), request.description(), + request.price(), request.stockQuantity()) + ); + return ApiResponse.success(ProductV1Dto.AdminProductResponse.from(product)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId + ) { + ProductInfo product = productService.getProduct(productId); + return ApiResponse.success(ProductV1Dto.AdminProductResponse.from(product)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestHeader("X-Loopers-Ldap") String ldap, + @RequestParam(required = false) Long brandId, + @PageableDefault(size = 20) Pageable pageable + ) { + Page page = productService.getProducts(brandId, pageable) + .map(ProductV1Dto.AdminProductResponse::from); + return ApiResponse.success(PageResponse.from(page)); + } + + @PutMapping("/{productId}") + public ApiResponse updateProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId, + @Valid @RequestBody ProductV1Dto.UpdateRequest request + ) { + ProductInfo product = productService.update( + productId, new ProductUpdateCommand(request.name(), request.description(), + request.price(), request.stockQuantity(), request.visibility()) + ); + return ApiResponse.success(ProductV1Dto.AdminProductResponse.from(product)); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct( + @RequestHeader("X-Loopers-Ldap") String ldap, + @PathVariable Long productId + ) { + productFacade.delete(productId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..f7d7720cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSort; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo product = productFacade.getActiveProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(product)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSort sort, + @PageableDefault(size = 20) Pageable pageable + ) { + Page page = productFacade.getActiveProducts(brandId, sort, pageable) + .map(ProductV1Dto.ProductResponse::from); + + return ApiResponse.success(PageResponse.from(page)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java new file mode 100644 index 000000000..1ff81ac36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.Product; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + BrandInfo brand, + String name, + String description, + Integer price, + Integer stockQuantity, + Integer likeCount + ) { + public record BrandInfo(String name) {} + + public static ProductResponse from(ProductInfo productInfo) { + return new ProductResponse( + productInfo.id(), + new BrandInfo(productInfo.brand().name()), + productInfo.name(), + productInfo.description(), + productInfo.price(), + productInfo.stockQuantity(), + productInfo.likeCount() + ); + } + } + + public record AdminProductResponse( + Long id, + Long brandId, + String name, + String description, + Integer price, + Integer stockQuantity, + Integer likeCount, + Product.Visibility visibility, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static AdminProductResponse from(ProductInfo productInfo) { + return new AdminProductResponse( + productInfo.id(), + productInfo.brand().id(), + productInfo.name(), + productInfo.description(), + productInfo.price(), + productInfo.stockQuantity(), + productInfo.likeCount(), + productInfo.visibility(), + productInfo.createdAt(), + productInfo.updatedAt() + ); + } + } + + public record CreateRequest( + @NotNull Long brandId, + @NotBlank(message = "상품 이름은 필수값입니다.") String name, + String description, + @NotNull Integer price, + @NotNull Integer stockQuantity + ) {} + + public record UpdateRequest( + @NotBlank(message = "상품 이름은 필수값입니다.") String name, + String description, + @NotNull Integer price, + @NotNull Integer stockQuantity, + Product.Visibility visibility + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java index 90ae6e0e1..9ef381bb0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/SignUpV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.SignUpCommand; -import com.loopers.domain.user.SignUpService; +import com.loopers.application.user.SignUpService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.user.dto.UserV1Dto; import jakarta.validation.Valid; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index d4d219daa..77a454cca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -2,8 +2,9 @@ import com.loopers.application.user.UserInfo; import com.loopers.application.user.UpdatePasswordCommand; -import com.loopers.domain.user.UserService; +import com.loopers.application.user.UserService; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginUser; import com.loopers.interfaces.api.user.dto.UserV1Dto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -22,18 +23,18 @@ public class UserV1Controller { private final UserService userService; @GetMapping("/me") - public ApiResponse getMyInfo(@RequestHeader("X-Loopers-LoginId") String loginId) { - UserInfo userInfo = userService.getMyInfo(loginId); + public ApiResponse getMyInfo(@LoginUser Long userId) { + UserInfo userInfo = userService.getMyInfo(userId); return ApiResponse.success(UserV1Dto.UserResponse.from(userInfo)); } @PatchMapping("/me/password") public ApiResponse updatePassword( - @RequestHeader("X-Loopers-LoginId") String loginId, + @LoginUser Long userId, @RequestHeader("X-Loopers-LoginPw") String currentPassword, @Valid @RequestBody UserV1Dto.UpdatePasswordRequest request ) { - userService.updatePassword(UpdatePasswordCommand.from(loginId, currentPassword, request)); + userService.updatePassword(UpdatePasswordCommand.from(userId, currentPassword, request)); return ApiResponse.success(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java new file mode 100644 index 000000000..a9291d950 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.config; + +import com.loopers.interfaces.api.auth.LoginUserArgumentResolver; +import com.loopers.interfaces.api.auth.LoginUserInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final LoginUserInterceptor loginUserInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(loginUserInterceptor) + .addPathPatterns("/api/v1/**") + .excludePathPatterns( + "/api/v1/users", // POST: 회원가입 + "/api/v1/brands/**", // GET: 브랜드 조회 + "/api/v1/products", // GET: 상품 목록 + "/api/v1/products/*", // GET: 상품 상세 (* 는 '/' 미포함 → /likes 는 여전히 인터셉터 적용) + "/api/v1/examples/**" // GET: 예시 + ); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginUserArgumentResolver()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..b521caaf7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,9 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + ALREADY_LIKED(HttpStatus.BAD_REQUEST, "ALREADY_LIKED", "이미 좋아요한 상품입니다."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..2bcd98af8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,106 @@ +package com.loopers.application.brand; + +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductCreateCommand; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.InMemoryBrandRepository; +import com.loopers.domain.like.InMemoryLikeRepository; +import com.loopers.domain.product.InMemoryProductRepository; +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 static org.assertj.core.api.Assertions.assertThat; + +public class BrandFacadeTest { + private InMemoryBrandRepository brandRepository; + private InMemoryProductRepository productRepository; + private InMemoryLikeRepository likeRepository; + private BrandService brandService; + private ProductService productService; + private LikeService likeService; + private BrandFacade brandFacade; + + @BeforeEach + void setUp() { + brandRepository = new InMemoryBrandRepository(); + productRepository = new InMemoryProductRepository(); + likeRepository = new InMemoryLikeRepository(); + brandService = new BrandService(brandRepository); + productService = new ProductService(productRepository); + likeService = new LikeService(likeRepository); + brandFacade = new BrandFacade(brandService, productService, likeService); + } + + @DisplayName("브랜드 삭제 시, ") + @Nested + class Delete { + @DisplayName("소속 상품이 모두 soft delete 된다.") + @Test + void softDeletesAllProducts_whenBrandIsDeleted() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + ProductInfo product1 = productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + ProductInfo product2 = productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5)); + + // act + brandFacade.delete(brand.id()); + + // assert + assertThat(productRepository.findById(product1.id()).orElseThrow().getDeletedAt()).isNotNull(); + assertThat(productRepository.findById(product2.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + + @DisplayName("소속 상품의 좋아요가 모두 삭제된다.") + @Test + void deletesAllLikes_whenBrandIsDeleted() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + ProductInfo product1 = productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + ProductInfo product2 = productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5)); + BrandInfo anotherBrand = brandService.register("아디다스", "스포츠 브랜드"); + ProductInfo anotherProduct = productService.register(new ProductCreateCommand(anotherBrand.id(), "울트라부스트", "러닝화", 180000, 3)); + + long userId = 1L; + likeService.register(userId, product1.id()); + likeService.register(userId, product2.id()); + likeService.register(userId, anotherProduct.id()); + + // act + brandFacade.delete(brand.id()); + + // assert + assertThat(likeRepository.findByUserId(userId)) + .extracting("productId") + .doesNotContain(product1.id(), product2.id()); + } + + @DisplayName("브랜드가 soft delete 된다.") + @Test + void softDeletesBrand() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + + // act + brandFacade.delete(brand.id()); + + // assert + assertThat(brandRepository.findById(brand.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + + @DisplayName("소속 상품이 없어도 브랜드만 soft delete 된다.") + @Test + void softDeletesBrandOnly_whenNoProducts() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + + // act + brandFacade.delete(brand.id()); + + // assert + assertThat(brandRepository.findById(brand.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java new file mode 100644 index 000000000..806609cbd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -0,0 +1,151 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.InMemoryBrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class BrandServiceTest { + private InMemoryBrandRepository brandRepository; + private BrandService brandService; + + @BeforeEach + void setUp() { + brandRepository = new InMemoryBrandRepository(); + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록 시, ") + @Nested + class Register { + @DisplayName("정상적으로 등록된다.") + @Test + void registersBrand() { + // arrange + String name = "나이키"; + String description = "스포츠 브랜드"; + + // act + BrandInfo brand = brandService.register(name, description); + + // assert + assertAll( + () -> assertThat(brand.name()).isEqualTo(name), + () -> assertThat(brand.description()).isEqualTo(description) + ); + } + } + + @DisplayName("브랜드 조회 시, ") + @Nested + class GetBrand { + @DisplayName("존재하는 브랜드를 조회하면 정상 반환된다.") + @Test + void returnsBrand_whenExists() { + // arrange + BrandInfo saved = brandService.register("나이키", "스포츠 브랜드"); + + // act + BrandInfo found = brandService.getBrand(saved.id()); + + // assert + assertThat(found.name()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.getBrand(99999L); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드를 조회하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenDeleted() { + // arrange + BrandInfo saved = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(saved.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.getBrand(saved.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정 시, ") + @Nested + class Update { + @DisplayName("정상적으로 수정된다.") + @Test + void updatesBrand() { + // arrange + BrandInfo saved = brandService.register("나이키", "스포츠 브랜드"); + + // act + BrandInfo updated = brandService.update(saved.id(), "new 나이키", "새로운 스포츠 브랜드"); + + // assert + assertAll( + () -> assertThat(updated.name()).isEqualTo("new 나이키"), + () -> assertThat(updated.description()).isEqualTo("새로운 스포츠 브랜드") + ); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.update(Long.MAX_VALUE, "아디다스", "독일 스포츠 브랜드"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시, ") + @Nested + class Delete { + @DisplayName("정상적으로 soft delete 된다.") + @Test + void deletesBrand() { + // arrange + BrandInfo saved = brandService.register("나이키", "스포츠 브랜드"); + + // act + brandService.delete(saved.id()); + + // assert + assertThat(brandRepository.findById(saved.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + brandService.delete(Long.MAX_VALUE); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..5b137ff64 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,163 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductCreateCommand; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductService; +import com.loopers.application.product.ProductUpdateCommand; +import com.loopers.domain.like.InMemoryLikeRepository; +import com.loopers.domain.product.InMemoryProductRepository; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class LikeFacadeTest { + + private InMemoryLikeRepository likeRepository; + private InMemoryProductRepository productRepository; + private LikeService likeService; + private ProductService productService; + private LikeFacade likeFacade; + + @BeforeEach + void setUp() { + likeRepository = new InMemoryLikeRepository(); + productRepository = new InMemoryProductRepository(); + likeService = new LikeService(likeRepository); + productService = new ProductService(productRepository); + likeFacade = new LikeFacade(likeService, productService); + } + + @DisplayName("좋아요 등록 시, ") + @Nested + class Register { + + @DisplayName("성공하면 상품 likeCount가 1 증가한다.") + @Test + void increases_like_count_by_1_on_success() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + + // act + likeFacade.register(userId, product.id()); + + // assert + assertThat(productService.getProduct(product.id()).likeCount()).isEqualTo(1); + } + + @DisplayName("이미 좋아요한 상품이면 예외가 발생한다.") + @Test + void throws_when_already_liked() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + likeFacade.register(userId, product.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + likeFacade.register(userId, product.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.ALREADY_LIKED); + } + } + + @DisplayName("좋아요 취소 시, ") + @Nested + class Cancel { + + @DisplayName("좋아요가 있을 때 취소하면 likeCount가 1 감소한다.") + @Test + void decreases_like_count_by_1_when_like_exists() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + likeFacade.register(userId, product.id()); + + // act + likeFacade.cancel(userId, product.id()); + + // assert + assertThat(productService.getProduct(product.id()).likeCount()).isEqualTo(0); + } + + @DisplayName("좋아요가 없을 때 취소하면 예외 없이 처리되고 likeCount는 감소하지 않는다.") + @Test + void noop_when_like_does_not_exist() { + // arrange + long userId = 1L; + ProductInfo product = productService.register( + new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10) + ); + + long productId = product.id(); + int before = productService.getProduct(productId).likeCount(); + + // act + likeFacade.cancel(userId, productId); + + // assert + int after = productService.getProduct(productId).likeCount(); + assertThat(after).isEqualTo(before); + } + } + + @DisplayName("좋아요 목록 조회 시, ") + @Nested + class GetLikes { + + @DisplayName("삭제된 상품의 좋아요는 제외된다.") + @Test + void excludesLikes_whenProductIsDeleted() { + // arrange + long userId = 1L; + ProductInfo activeProduct = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + ProductInfo deletedProduct = productService.register(new ProductCreateCommand(1L, "조던", "농구화", 200000, 5)); + + likeService.register(userId, activeProduct.id()); + likeService.register(userId, deletedProduct.id()); + + productService.delete(deletedProduct.id()); + + // act + List likes = likeFacade.getLikedProductsByUserId(userId); + + // assert + assertThat(likes) + .extracting(info -> info.product().id()) + .doesNotContain(deletedProduct.id()); + } + + @DisplayName("HIDDEN 상태인 상품의 좋아요는 제외된다.") + @Test + void excludesLikes_whenProductIsHidden() { + // arrange + long userId = 1L; + ProductInfo activeProduct = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + ProductInfo hiddenProduct = productService.register(new ProductCreateCommand(1L, "조던", "농구화", 200000, 5)); + + likeService.register(userId, activeProduct.id()); + likeService.register(userId, hiddenProduct.id()); + + productService.update(hiddenProduct.id(), new ProductUpdateCommand(hiddenProduct.name(), hiddenProduct.description(), hiddenProduct.price(), hiddenProduct.stockQuantity(), Product.Visibility.HIDDEN)); + + // act + List likes = likeFacade.getLikedProductsByUserId(userId); + + // assert + assertThat(likes) + .extracting(info -> info.product().id()) + .doesNotContain(hiddenProduct.id()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java new file mode 100644 index 000000000..40c50c389 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,69 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.InMemoryLikeRepository; +import com.loopers.domain.like.Like; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class LikeServiceTest { + + private InMemoryLikeRepository likeRepository; + private LikeService likeService; + + @BeforeEach + void setUp() { + likeRepository = new InMemoryLikeRepository(); + likeService = new LikeService(likeRepository); + } + + @DisplayName("좋아요 등록 시, ") + @Nested + class Register { + @DisplayName("이미 좋아요한 상품이면 예외가 발생한다.") + @Test + void throwsException_whenAlreadyLiked() { + // arrange + likeRepository.save(Like.create(1L, 100L)); + + // act & assert + assertThatThrownBy(() -> likeService.register(1L, 100L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.ALREADY_LIKED)); + } + } + + @DisplayName("좋아요 취소 시, ") + @Nested + class Cancel { + + @DisplayName("좋아요가 존재하면 삭제 후 true를 반환한다.") + @Test + void returnsTrue_whenLikeExists() { + // arrange + likeRepository.save(Like.create(1L, 100L)); + + // act + boolean result = likeService.cancel(1L, 100L); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("좋아요가 존재하지 않으면 false를 반환한다.") + @Test + void returnsFalse_whenLikeDoesNotExist() { + // act + boolean result = likeService.cancel(1L, 100L); + + // assert + assertThat(result).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..979222f13 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,81 @@ +package com.loopers.application.order; + +import com.loopers.application.product.ProductCreateCommand; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductService; +import com.loopers.domain.order.InMemoryOrderItemRepository; +import com.loopers.domain.order.InMemoryOrderRepository; +import com.loopers.domain.product.InMemoryProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderFacadeTest { + + private InMemoryProductRepository productRepository; + private ProductService productService; + private InMemoryOrderRepository orderRepository; + private InMemoryOrderItemRepository orderItemRepository; + private OrderService orderService; + private OrderFacade orderFacade; + + @BeforeEach + void setUp() { + productRepository = new InMemoryProductRepository(); + productService = new ProductService(productRepository); + orderRepository = new InMemoryOrderRepository(); + orderItemRepository = new InMemoryOrderItemRepository(); + orderService = new OrderService(orderRepository, orderItemRepository); + orderFacade = new OrderFacade(orderService, productService); + } + + @DisplayName("주문 생성 시, ") + @Nested + class CreateOrder { + + @DisplayName("성공하면 재고가 차감된다.") + @Test + void decreasesStock_whenOrderSucceeds() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", null, 150000, 5)); + OrderCreateCommand command = new OrderCreateCommand( + userId, + List.of(new OrderItemCommand(product.id(), 2)) + ); + + // act + orderFacade.createOrder(command); + + // assert + assertThat(productService.getProduct(product.id()).stockQuantity()).isEqualTo(3); + } + + @DisplayName("성공하면 OrderInfo를 반환한다.") + @Test + void returnsOrderInfo_whenOrderSucceeds() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", null, 150000, 5)); + OrderCreateCommand command = new OrderCreateCommand( + userId, + List.of(new OrderItemCommand(product.id(), 2)) + ); + + // act + OrderInfo order = orderFacade.createOrder(command); + + // assert + assertAll( + () -> assertThat(order.userId()).isEqualTo(userId), + () -> assertThat(order.totalAmount()).isEqualTo(300000L) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java new file mode 100644 index 000000000..d0a33d14d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,144 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.InMemoryOrderItemRepository; +import com.loopers.domain.order.InMemoryOrderRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItemSnapshot; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +class OrderServiceTest { + + private InMemoryOrderRepository orderRepository; + private InMemoryOrderItemRepository orderItemRepository; + private OrderService orderService; + + @BeforeEach + void setUp() { + orderRepository = new InMemoryOrderRepository(); + orderItemRepository = new InMemoryOrderItemRepository(); + orderService = new OrderService(orderRepository, orderItemRepository); + } + + @DisplayName("주문 항목 검증 시, ") + @Nested + class ValidateItems { + + @DisplayName("항목이 비어있으면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsAreEmpty() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.validateItems(List.of()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + // arrange + List items = List.of(new OrderItemCommand(1L, 0)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.validateItems(items); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + // arrange + List items = List.of(new OrderItemCommand(1L, -1)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.validateItems(items); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("중복 상품이 포함되면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProducts() { + // arrange + List items = List.of( + new OrderItemCommand(1L, 1), + new OrderItemCommand(1L, 2) + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.validateItems(items); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 생성 시, ") + @Nested + class CreateOrder { + + @DisplayName("정상적으로 OrderInfo를 반환한다.") + @Test + void createsOrderInfo_whenValid() { + // arrange + long userId = 1L; + List snapshots = List.of(new OrderItemSnapshot(1L, "에어맥스", 150000L, 2)); + long expectedTotal = 150000L * 2; + + // act + OrderInfo order = orderService.placeOrder(userId, snapshots); + + // assert + assertAll( + () -> assertThat(order.userId()).isEqualTo(userId), + () -> assertThat(order.totalAmount()).isEqualTo(expectedTotal), + () -> assertThat(order.status()).isEqualTo(Order.Status.ORDERED) + ); + } + } + + @DisplayName("주문 단건 조회 시, ") + @Nested + class GetOrder { + + @DisplayName("타인의 주문을 조회하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderNotOwned() { + // arrange + long ownerId = 1L; + List snapshots = List.of(new OrderItemSnapshot(1L, "에어맥스", 150000L, 1)); + OrderInfo order = orderService.placeOrder(ownerId, snapshots); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.getOrder(999L, order.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..c2de9360a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,168 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandService; +import com.loopers.application.like.LikeService; +import com.loopers.domain.brand.InMemoryBrandRepository; +import com.loopers.domain.like.InMemoryLikeRepository; +import com.loopers.domain.product.InMemoryProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductFacadeTest { + private BrandService brandService; + private ProductService productService; + private LikeService likeService; + private InMemoryLikeRepository likeRepository; + private InMemoryProductRepository productRepository; + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + brandService = new BrandService(new InMemoryBrandRepository()); + productRepository = new InMemoryProductRepository(); + likeRepository = new InMemoryLikeRepository(); + productService = new ProductService(productRepository); + likeService = new LikeService(likeRepository); + productFacade = new ProductFacade(brandService, productService, likeService); + } + + @DisplayName("활성 상품 단건 조회 시, ") + @Nested + class GetActiveProduct { + @DisplayName("상품 응답에 브랜드 이름이 포함된다.") + @Test + void returnsBrandName_whenProductIsActive() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + ProductInfo product = productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + + // act + ProductInfo result = productFacade.getActiveProduct(product.id()); + + // assert + assertThat(result.brand().name()).isEqualTo("나이키"); + } + } + + @DisplayName("활성 상품 목록 조회 시, ") + @Nested + class GetActiveProducts { + @DisplayName("각 상품 응답에 브랜드 이름이 포함된다.") + @Test + void returnsBrandName_forEachProduct() { + // arrange + BrandInfo nike = brandService.register("나이키", "스포츠 브랜드"); + BrandInfo adidas = brandService.register("아디다스", "스포츠 브랜드"); + productService.register(new ProductCreateCommand(nike.id(), "에어맥스", "신발", 150000, 10)); + productService.register(new ProductCreateCommand(adidas.id(), "슈퍼스타", "신발", 120000, 8)); + + // act + Page result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); + + // assert + assertAll( + () -> assertThat(result.getContent()).extracting(p -> p.brand().name()) + .containsExactlyInAnyOrder("나이키", "아디다스") + ); + } + + @DisplayName("같은 브랜드의 여러 상품 조회 시 브랜드 이름이 모두 일치한다.") + @Test + void returnsSameBrandName_whenMultipleProductsOfSameBrand() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5)); + + // act + Page result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).extracting(p -> p.brand().name()) + .containsOnly("나이키"); + } + } + + @DisplayName("상품 삭제 시, ") + @Nested + class Delete { + @DisplayName("해당 상품의 좋아요가 모두 삭제된다.") + @Test + void deletesAllLikes_whenProductIsDeleted() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + ProductInfo product = productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + ProductInfo anotherProduct = productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5)); + + long userId = 1L; + likeService.register(userId, product.id()); + likeService.register(userId, anotherProduct.id()); + + // act + productFacade.delete(product.id()); + + // assert + assertThat(likeRepository.findByUserId(userId)) + .extracting("productId") + .doesNotContain(product.id()) + .contains(anotherProduct.id()); + } + + @DisplayName("상품이 soft delete 된다.") + @Test + void softDeletesProduct() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + ProductInfo product = productService.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + + // act + productFacade.delete(product.id()); + + // assert + assertThat(productRepository.findById(product.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + } + + @DisplayName("상품 등록 시, ") + @Nested + class Register { + @DisplayName("존재하지 않는 브랜드로 등록하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenBrandNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + productFacade.register(new ProductCreateCommand(99999L, "에어맥스", "신발", 150000, 10)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드로 등록하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenBrandIsDeleted() { + // arrange + BrandInfo brand = brandService.register("나이키", "스포츠 브랜드"); + brandService.delete(brand.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productFacade.register(new ProductCreateCommand(brand.id(), "에어맥스", "신발", 150000, 10)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java new file mode 100644 index 000000000..e0516f088 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -0,0 +1,191 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.InMemoryProductRepository; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductServiceTest { + private static final Long BRAND_ID = 1L; + + private InMemoryProductRepository productRepository; + private ProductService productService; + + @BeforeEach + void setUp() { + productRepository = new InMemoryProductRepository(); + productService = new ProductService(productRepository); + } + + @DisplayName("상품 등록 시, ") + @Nested + class Register { + @DisplayName("정상적으로 등록된다.") + @Test + void registersProduct() { + // act + ProductInfo product = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 10)); + + // assert + assertAll( + () -> assertThat(product.brand().id()).isEqualTo(BRAND_ID), + () -> assertThat(product.name()).isEqualTo("에어맥스"), + () -> assertThat(product.price()).isEqualTo(150000), + () -> assertThat(product.stockQuantity()).isEqualTo(10) + ); + } + } + + @DisplayName("상품 조회 시, ") + @Nested + class GetProduct { + @DisplayName("존재하는 상품을 조회하면 정상 반환된다.") + @Test + void returnsProduct_whenExists() { + // arrange + ProductInfo saved = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 10)); + + // act + ProductInfo found = productService.getProduct(saved.id()); + + // assert + assertThat(found.name()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 상품을 조회하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProduct(Long.MAX_VALUE); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 상품을 조회하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenDeleted() { + // arrange + ProductInfo saved = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 10)); + productService.delete(saved.id()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProduct(saved.id()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 수정 시, ") + @Nested + class Update { + @DisplayName("정상적으로 수정된다.") + @Test + void updatesProduct() { + // arrange + ProductInfo saved = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 10)); + + // act + ProductInfo updated = productService.update(saved.id(), new ProductUpdateCommand("조던", "농구화", 200000, 5, null)); + + // assert + assertAll( + () -> assertThat(updated.name()).isEqualTo("조던"), + () -> assertThat(updated.description()).isEqualTo("농구화"), + () -> assertThat(updated.price()).isEqualTo(200000), + () -> assertThat(updated.stockQuantity()).isEqualTo(5) + ); + } + + @DisplayName("존재하지 않는 상품을 수정하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.update(Long.MAX_VALUE, new ProductUpdateCommand("조던", "농구화", 200000, 5, null)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 삭제 시, ") + @Nested + class Delete { + @DisplayName("정상적으로 soft delete 된다.") + @Test + void deletesProduct() { + // arrange + ProductInfo saved = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 10)); + + // act + productService.delete(saved.id()); + + // assert + assertThat(productRepository.findById(saved.id()).orElseThrow().getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 상품을 삭제하면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFoundException_whenNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.delete(Long.MAX_VALUE); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("재고 차감 시, ") + @Nested + class DecreaseStock { + @DisplayName("재고가 부족하면 INSUFFICIENT_STOCK 예외가 발생한다.") + @Test + void throwsInsufficientStock_whenStockIsNotEnough() { + // arrange + ProductInfo product = productService.register(new ProductCreateCommand(BRAND_ID, "에어맥스", "신발", 150000, 1)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.decreaseStock(List.of(new OrderItemCommand(product.id(), 2))); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.INSUFFICIENT_STOCK); + } + } + + @DisplayName("상품 목록 조회(주문용) 시, ") + @Nested + class GetActiveProductsByIdsOrThrow { + @DisplayName("존재하지 않는 상품이 포함되면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductNotExists() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getActiveProductsByIdsOrThrow(List.of(Long.MAX_VALUE)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceIntegrationTest.java similarity index 91% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceIntegrationTest.java index 6766179a5..52a57ac64 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceIntegrationTest.java @@ -1,6 +1,6 @@ -package com.loopers.domain.user; +package com.loopers.application.user; -import com.loopers.application.user.SignUpCommand; +import com.loopers.domain.user.User; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import com.loopers.infrastructure.user.UserJpaRepository; -import org.springframework.test.context.ActiveProfiles; import java.time.LocalDate; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceTest.java new file mode 100644 index 000000000..e3a9e2cef --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/SignUpServiceTest.java @@ -0,0 +1,162 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.InMemoryUserRepository; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.BcryptPasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SignUpServiceTest { + private InMemoryUserRepository userRepository; + private PasswordEncoder passwordEncoder; + private SignUpService signUpService; + + @BeforeEach + void setUp() { + userRepository = new InMemoryUserRepository(); + passwordEncoder = new BcryptPasswordEncoder(); + signUpService = new SignUpService(passwordEncoder, userRepository); + } + + @DisplayName("회원가입 시, ") + @Nested + class SignUp { + + @DisplayName("비밀번호를 암호화해서 저장한다.") + @Test + void encryptsPassword() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser123", + "ValidPass1!", + "박자바", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + signUpService.signUp(command); + + // assert + User savedUser = userRepository.findByLoginId("testUser123").orElse(null); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getPassword()).isNotEqualTo("ValidPass1!"); + } + + @DisplayName("loginId가 중복되면 CONFLICT 예외가 발생한다.") + @Test + void throwsConflictException_whenLoginIdIsDuplicated() { + // arrange + SignUpCommand firstCommand = new SignUpCommand( + "duplicateId", + "ValidPass1!", + "박자바", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + signUpService.signUp(firstCommand); + + SignUpCommand secondCommand = new SignUpCommand( + "duplicateId", + "ValidPass2!", + "김자바", + LocalDate.of(1995, 5, 20), + "second@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + signUpService.signUp(secondCommand); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("password가 8자 미만이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooShort() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Short1!", + "박자바", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> signUpService.signUp(command)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 16자 초과이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordIsTooLong() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "VeryLongPass123!!", + "박자바", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> signUpService.signUp(command)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 공백이 포함되면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordContainsWhitespace() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Pass 1234!", + "박자바", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> signUpService.signUp(command)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 생년월일이 포함되면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPasswordContainsBirthDate() { + // arrange + SignUpCommand command = new SignUpCommand( + "testUser", + "Pass19900115!", + "박자바", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + CoreException result = assertThrows(CoreException.class, () -> signUpService.signUp(command)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java similarity index 75% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index 292d92314..7798b0311 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -1,7 +1,7 @@ -package com.loopers.domain.user; +package com.loopers.application.user; -import com.loopers.application.user.UpdatePasswordCommand; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -38,14 +38,15 @@ void tearDown() { void getMyInfo_returnsUserInfoWithMaskedName() { // arrange String loginId = "testUser123"; - User user = UserFixture.builder() - .loginId(loginId) - .name("박자바") - .build(); - userJpaRepository.save(user); + User user = userJpaRepository.save( + UserFixture.builder() + .loginId(loginId) + .name("박자바") + .build() + ); // act - UserInfo result = userService.getMyInfo(loginId); + UserInfo result = userService.getMyInfo(user.getId()); // assert assertAll( @@ -61,14 +62,15 @@ void updatePassword_updatesPasswordInDb() { String loginId = "testUser123"; String currentPassword = "OldPass1!"; String encode = bCryptPasswordEncoder.encode(currentPassword); - User user = UserFixture.builder() - .loginId(loginId) - .password(encode) - .build(); - userJpaRepository.save(user); + User user = userJpaRepository.save( + UserFixture.builder() + .loginId(loginId) + .password(encode) + .build() + ); String newPassword = "NewPass1!"; - UpdatePasswordCommand command = new UpdatePasswordCommand(loginId, currentPassword, newPassword); + UpdatePasswordCommand command = new UpdatePasswordCommand(user.getId(), currentPassword, newPassword); // act userService.updatePassword(command); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java similarity index 89% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java index bf0516c97..bcaeb82c0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java @@ -1,7 +1,9 @@ -package com.loopers.domain.user; +package com.loopers.application.user; -import com.loopers.application.user.UpdatePasswordCommand; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.InMemoryUserRepository; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; import com.loopers.infrastructure.user.BcryptPasswordEncoder; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -35,7 +37,7 @@ void getMyInfo_masks_last_character_of_name() { userRepository.save(user); // when - UserInfo myInfo = userService.getMyInfo(user.getLoginId()); + UserInfo myInfo = userService.getMyInfo(user.getId()); // then assertThat(myInfo.name()).isEqualTo("테스*"); @@ -55,7 +57,7 @@ void throwsException_whenCurrentPasswordNotMatches() { .build(); userRepository.save(user); - UpdatePasswordCommand command = new UpdatePasswordCommand(user.getLoginId(), "WrongPass1!", "NewPass1!"); + UpdatePasswordCommand command = new UpdatePasswordCommand(user.getId(), "WrongPass1!", "NewPass1!"); // when CoreException result = assertThrows(CoreException.class, () -> { @@ -77,7 +79,7 @@ void throwsException_whenNewPasswordSameAsCurrent() { .build(); userRepository.save(user); - UpdatePasswordCommand command = new UpdatePasswordCommand(user.getLoginId(), rawPassword, rawPassword); + UpdatePasswordCommand command = new UpdatePasswordCommand(user.getId(), rawPassword, rawPassword); // when CoreException result = assertThrows(CoreException.class, () -> { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..d52c15039 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,127 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class BrandTest { + + @DisplayName("브랜드 생성 시, ") + @Nested + class Create { + @DisplayName("name과 description이 유효하면 정상적으로 생성된다.") + @Test + void createsBrand_whenFieldsAreValid() { + // act + Brand brand = Brand.create("나이키", "Just Do It!"); + + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("나이키"), + () -> assertThat(brand.getDescription()).isEqualTo("Just Do It!") + ); + } + + @DisplayName("description이 null이어도 정상적으로 생성된다.") + @Test + void createsBrand_whenDescriptionIsNull() { + // act + Brand brand = Brand.create("나이키", null); + + // assert + assertThat(brand.getDescription()).isNull(); + } + + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Brand.create(null, "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 빈 문자열이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsEmpty() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Brand.create("", "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백 문자열이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Brand.create(" ", "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드 수정 시, ") + @Nested + class Update { + @DisplayName("name과 description을 변경할 수 있다.") + @Test + void updatesBrand_whenFieldsAreValid() { + // arrange + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + // act + brand.update("아디다스", "독일 스포츠 브랜드"); + + // assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("아디다스"), + () -> assertThat(brand.getDescription()).isEqualTo("독일 스포츠 브랜드") + ); + } + + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenUpdateNameIsNull() { + // arrange + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update(null, "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenUpdateNameIsBlank() { + // arrange + Brand brand = Brand.create("나이키", "스포츠 브랜드"); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + brand.update(" ", "설명"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/InMemoryBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/InMemoryBrandRepository.java new file mode 100644 index 000000000..e0c7879e9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/InMemoryBrandRepository.java @@ -0,0 +1,52 @@ +package com.loopers.domain.brand; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryBrandRepository implements BrandRepository { + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Brand save(Brand brand) { + if (brand.getId() == 0L) { + try { + var idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(brand.getId(), brand); + return brand; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + var list = store.values().stream() + .filter(b -> b.getDeletedAt() == null) + .toList(); + return new PageImpl<>(list, pageable, list.size()); + } + + @Override + public List findAllByIdIn(Collection ids) { + return store.values().stream() + .filter(b -> ids.contains(b.getId())) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.java new file mode 100644 index 000000000..ed158b64c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/InMemoryLikeRepository.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryLikeRepository implements LikeRepository { + + private final List store = new ArrayList<>(); + private final AtomicLong sequence = new AtomicLong(1); + + @Override + public Like save(Like like) { + store.add(like); + return like; + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.stream() + .filter(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public int deleteByUserIdAndProductId(Long userId, Long productId) { + int before = store.size(); + store.removeIf(l -> l.getUserId().equals(userId) && l.getProductId().equals(productId)); + return before - store.size(); + } + + @Override + public void deleteAllByProductIdIn(List productIds) { + store.removeIf(l -> productIds.contains(l.getProductId())); + } + + @Override + public List findByUserId(Long userId) { + return store.stream() + .filter(l -> l.getUserId().equals(userId)) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderItemRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderItemRepository.java new file mode 100644 index 000000000..afc3b7d0b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderItemRepository.java @@ -0,0 +1,38 @@ +package com.loopers.domain.order; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryOrderItemRepository implements OrderItemRepository { + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public OrderItem save(OrderItem orderItem) { + if (orderItem.getId() == 0L) { + try { + var idField = orderItem.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(orderItem, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(orderItem.getId(), orderItem); + return orderItem; + } + + @Override + public List saveAll(List orderItems) { + return orderItems.stream().map(this::save).toList(); + } + + @Override + public List findByOrderId(Long orderId) { + return store.values().stream() + .filter(item -> item.getOrderId().equals(orderId)) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderRepository.java new file mode 100644 index 000000000..abbf134d6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/InMemoryOrderRepository.java @@ -0,0 +1,61 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryOrderRepository implements OrderRepository { + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Order save(Order order) { + if (order.getId() == 0L) { + try { + var idField = order.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(order, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(order.getId(), order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return store.values().stream() + .filter(o -> o.getUserId().equals(userId)) + .filter(o -> o.getCreatedAt() != null) + .filter(o -> !o.getCreatedAt().isBefore(startAt) && !o.getCreatedAt().isAfter(endAt)) + .sorted(Comparator.comparing(Order::getId).reversed()) + .toList(); + } + + @Override + public Page findAll(Pageable pageable) { + List list = store.values().stream() + .sorted(Comparator.comparing(Order::getId).reversed()) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), list.size()); + List content = start >= list.size() ? List.of() : list.subList(start, end); + + return new PageImpl<>(content, pageable, list.size()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..df4954007 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemTest { + + @DisplayName("주문 항목 생성 시, ") + @Nested + class Create { + + @DisplayName("수량이 1 이상이면 정상적으로 생성된다.") + @Test + void createsOrderItem_whenQuantityIsAtLeastOne() { + // act + OrderItem item = OrderItem.create(1L, 10L, "에어맥스", 150000, 1); + + // assert + assertThat(item.getQuantity()).isEqualTo(1); + } + + @DisplayName("수량이 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(1L, 10L, "에어맥스", 150000, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(1L, 10L, "에어맥스", 150000, 0); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + OrderItem.create(1L, 10L, "에어맥스", 150000, -1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..51a431114 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,29 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderTest { + + @DisplayName("주문 생성 시, 상태는 ORDERED로 초기화되고 총 금액이 계산된다.") + @Test + void setsStatusToOrdered_onCreate() { + // arrange + List snapshots = List.of(new OrderItemSnapshot(1L, "에어맥스", 150000L, 2)); + + // act + Order order = Order.create(1L, snapshots); + + // assert + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalAmount()).isEqualTo(300000L), + () -> assertThat(order.getStatus()).isEqualTo(Order.Status.ORDERED) + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java new file mode 100644 index 000000000..7ee69e3bd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java @@ -0,0 +1,107 @@ +package com.loopers.domain.product; + +import com.loopers.domain.product.ProductOrder; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; + +public class InMemoryProductRepository implements ProductRepository { + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Product save(Product product) { + if (product.getId() == 0L) { + try { + var idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(product.getId(), product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Page findActiveProducts(Long brandId, ProductOrder sort, Pageable pageable) { + Stream stream = store.values().stream() + .filter(Product::isActive); + + if (brandId != null) { + stream = stream.filter(p -> p.getBrandId().equals(brandId)); + } + + Comparator comparator = switch (sort) { + case PRICE_ASC -> Comparator.comparing(Product::getPrice); + // likes_desc는 Like 도메인 구현 전이므로 인메모리에서 id 기준으로 대체 + case LIKES_DESC, LATEST -> Comparator.comparing(Product::getId).reversed(); + }; + + List list = stream.sorted(comparator).toList(); + return new PageImpl<>(list, pageable, list.size()); + } + + @Override + public Page findAllProducts(Long brandId, Pageable pageable) { + List list = store.values().stream() + .filter(p -> p.getDeletedAt() == null) + .filter(p -> brandId == null || p.getBrandId().equals(brandId)) + .sorted(Comparator.comparing(Product::getId).reversed()) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), list.size()); + List content = start >= list.size() ? List.of() : list.subList(start, end); + + return new PageImpl<>(content, pageable, list.size()); + } + + @Override + public List findAllByBrandIdAndDeletedAtIsNull(Long brandId) { + return store.values().stream() + .filter(p -> p.getBrandId().equals(brandId) && p.getDeletedAt() == null) + .toList(); + } + + @Override + public List findAllByIdInAndDeletedAtIsNull(List ids) { + return store.values().stream() + .filter(p -> ids.contains(p.getId()) && p.getDeletedAt() == null) + .toList(); + } + + @Override + public boolean decreaseStockIfEnough(Long productId, Integer quantity) { + Product product = store.get(productId); + if (product == null) { + return false; + } + if (product.getDeletedAt() != null) { + return false; + } + if (product.getVisibility() != Product.Visibility.VISIBLE) { + return false; + } + if (product.getStockQuantity() < quantity) { + return false; + } + + // 재고 차감은 디비 원자적처리에 의존하므로 통합테스트에서 커버함 + return true; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..886f06cd9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,362 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductTest { + + @DisplayName("상품 생성 시, ") + @Nested + class Create { + @DisplayName("모든 필드 값이 유효하면 정상적으로 생성된다.") + @Test + void createsProduct_whenFieldsAreValid() { + // act + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("나이키 에어맥스"), + () -> assertThat(product.getDescription()).isEqualTo("신발"), + () -> assertThat(product.getPrice()).isEqualTo(150000), + () -> assertThat(product.getStockQuantity()).isEqualTo(10), + () -> assertThat(product.getVisibility()).isEqualTo(Product.Visibility.VISIBLE) + ); + } + + @DisplayName("description이 null이어도 정상적으로 생성된다.") + @Test + void createsProduct_whenDescriptionIsNull() { + // act + Product product = Product.create(1L, "나이키 에어맥스", null, 150000, 10); + + // assert + assertThat(product.getDescription()).isNull(); + } + + @DisplayName("brandId가 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenBrandIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(null, "나이키 에어맥스", "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, null, "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 빈 문자열이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsEmpty() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "", "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백 문자열이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, " ", "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPriceIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "나이키 에어맥스", "신발", null, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 0이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPriceIsZero() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "나이키 에어맥스", "신발", 0, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 음수면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPriceIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "나이키 에어맥스", "신발", -5, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("stockQuantity가 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenStockQuantityIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "나이키 에어맥스", "신발", 150000, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("stockQuantity가 0이어도 정상적으로 생성된다.") + @Test + void createsProduct_whenStockQuantityIsZero() { + // act + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 0); + + // assert + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("stockQuantity가 음수이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenStockQuantityIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + Product.create(1L, "나이키 에어맥스", "신발", 150000, -1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 수정 시, ") + @Nested + class Update { + @DisplayName("유효한 필드로 정상적으로 수정된다.") + @Test + void updatesProduct_whenFieldsAreValid() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + product.update("나이키 조던", "농구화", 200000, 5); + + // assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), // brandId 변경 불가 검증 + () -> assertThat(product.getName()).isEqualTo("나이키 조던"), + () -> assertThat(product.getDescription()).isEqualTo("농구화"), + () -> assertThat(product.getPrice()).isEqualTo(200000), + () -> assertThat(product.getStockQuantity()).isEqualTo(5) + ); + } + + @DisplayName("name이 null이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsNull() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update(null, "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 공백이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenNameIsBlank() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update(" ", "신발", 150000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 0이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPriceIsZero() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update("나이키 에어맥스", "신발", 0, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("price가 음수면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenPriceIsNegative() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update("나이키 에어맥스", "신발", -10000, 10); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("stockQuantity가 0이면 정상적으로 수정된다.") + @Test + void updatesProduct_whenStockQuantityIsZero() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + product.update("나이키 에어맥스", "신발", 150000, 0); + + // assert + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("stockQuantity가 음수이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenStockQuantityIsNegative() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.update("나이키 에어맥스", "신발", 150000, -1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("노출 여부 변경 시, ") + @Nested + class ChangeVisibility { + @DisplayName("VISIBLE에서 HIDDEN으로 변경할 수 있다.") + @Test + void changesVisibility_fromVisibleToHidden() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + product.changeVisibility(Product.Visibility.HIDDEN); + + // assert + assertThat(product.getVisibility()).isEqualTo(Product.Visibility.HIDDEN); + } + + @DisplayName("HIDDEN에서 VISIBLE로 변경할 수 있다.") + @Test + void changesVisibility_fromHiddenToVisible() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + product.changeVisibility(Product.Visibility.HIDDEN); + + // act + product.changeVisibility(Product.Visibility.VISIBLE); + + // assert + assertThat(product.getVisibility()).isEqualTo(Product.Visibility.VISIBLE); + } + } + + @DisplayName("좋아요 수 변경 시, ") + @Nested + class LikeCount { + + @DisplayName("기본값은 0이다.") + @Test + void likeCount_기본값은_0이다() { + // act + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("incrementLikeCount 호출 시 1 증가한다.") + @Test + void incrementLikeCount_호출시_1증가한다() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + product.increaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("decrementLikeCount 호출 시 1 감소한다.") + @Test + void decrementLikeCount_호출시_1감소한다() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + product.increaseLikeCount(); + + // act + product.decreaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("decrementLikeCount를 0에서 호출해도 음수가 되지 않는다.") + @Test + void decrementLikeCount_0에서_호출해도_음수가_되지_않는다() { + // arrange + Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); + + // act + product.decreaseLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java index 9c1535db1..305c3913b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/InMemoryUserRepository.java @@ -3,19 +3,38 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; public class InMemoryUserRepository implements UserRepository { private final Map storeByEmail = new HashMap<>(); private final Map storeByLoginId = new HashMap<>(); + private final Map storeById = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); @Override public void save(User user) { + if (user.getId() == 0L) { + try { + var idField = user.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + storeByEmail.put(user.getEmail(), user); storeByLoginId.put(user.getLoginId(), user); + storeById.put(user.getId(), user); } @Override public Optional findByLoginId(String loginId) { return Optional.ofNullable(storeByLoginId.get(loginId)); } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(storeById.get(id)); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java deleted file mode 100644 index b1b09d192..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpServiceTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.SignUpCommand; -import com.loopers.infrastructure.user.BcryptPasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.DisplayName; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; - -public class SignUpServiceTest { - private InMemoryUserRepository userRepository; - private PasswordEncoder passwordEncoder; - private SignUpValidator signUpValidator; - private SignUpService signUpService; - - @BeforeEach - void setUp() { - userRepository = new InMemoryUserRepository(); - passwordEncoder = new BcryptPasswordEncoder(); - signUpValidator = new SignUpValidator(userRepository); - signUpService = new SignUpService(signUpValidator, passwordEncoder, userRepository); - } - - @Test - @DisplayName("회원가입시 비밀번호를 암호화해서 저장한다.") - void signUp_encryptsPassword() { - // arrange - SignUpCommand command = new SignUpCommand( - "testUser123", - "ValidPass1!", - "박자바", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - signUpService.signUp(command); - - // assert - User savedUser = userRepository.findByLoginId("testUser123").orElse(null); - assertThat(savedUser).isNotNull(); - assertThat(savedUser.getPassword()).isNotEqualTo("ValidPass1!"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java deleted file mode 100644 index 4ac27b407..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/SignUpValidatorTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.application.user.SignUpCommand; -import com.loopers.infrastructure.user.BcryptPasswordEncoder; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class SignUpValidatorTest { - private InMemoryUserRepository userRepository; - private PasswordEncoder passwordEncoder; - private SignUpValidator signUpValidator; - private SignUpService signUpService; - - @BeforeEach - void setUp() { - userRepository = new InMemoryUserRepository(); - passwordEncoder = new BcryptPasswordEncoder(); - signUpValidator = new SignUpValidator(userRepository); - signUpService = new SignUpService(signUpValidator, passwordEncoder, userRepository); - } - - @Test - @DisplayName("회원가입시 loginId가 중복되면 예외가 발생한다.") - void validate_throwsException_whenLoginIdIsDuplicated() { - // arrange - SignUpCommand firstCommand = new SignUpCommand( - "duplicateId", - "ValidPass1!", - "박자바", - LocalDate.of(1990, 1, 15), - "first@example.com" - ); - signUpService.signUp(firstCommand); - - SignUpCommand secondCommand = new SignUpCommand( - "duplicateId", - "ValidPass2!", - "김자바", - LocalDate.of(1995, 5, 20), - "second@example.com" - ); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - signUpValidator.validate(secondCommand); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); - } - - @Test - @DisplayName("회원가입시 password가 8자 미만이면 예외가 발생한다.") - void validate_throwsException_whenPasswordIsTooShort() { - // arrange - SignUpCommand command = new SignUpCommand( - "testUser", - "Short1!", - "박자바", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - signUpValidator.validate(command); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @Test - @DisplayName("회원가입시 password가 16자 초과면 예외가 발생한다.") - void validate_throwsException_whenPasswordIsTooLong() { - // arrange - SignUpCommand command = new SignUpCommand( - "testUser", - "VeryLongPass123!!", - "박자바", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - signUpValidator.validate(command); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @Test - @DisplayName("회원가입시 password에 공백이 포함되면 예외가 발생한다.") - void validate_throwsException_whenPasswordContainsWhitespace() { - // arrange - SignUpCommand command = new SignUpCommand( - "testUser", - "Pass 1234!", - "박자바", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - signUpValidator.validate(command); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @Test - @DisplayName("회원가입시 password에 생년월일이 포함되면 예외가 발생한다.") - void validate_throwsException_whenPasswordContainsBirthDate() { - // arrange - SignUpCommand command = new SignUpCommand( - "testUser", - "Pass19900115!", - "박자바", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - signUpValidator.validate(command); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 72af6858a..01ef380ab 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -144,5 +144,22 @@ void createsUser_whenBirthDateIsValid() { // assert assertThat(user.getBirthDate()).isEqualTo(birthDate); } + + @DisplayName("birthDate가 미래이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequestException_whenBirthDateIsInFuture() { + // arrange + LocalDate futureDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + UserFixture.builder() + .birthDate(futureDate) + .build(); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java new file mode 100644 index 000000000..2d9450aad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java @@ -0,0 +1,295 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.brand.dto.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminBrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/brands"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final LikeJpaRepository likeJpaRepository; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminBrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + LikeJpaRepository likeJpaRepository, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.likeJpaRepository = likeJpaRepository; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, LDAP_VALUE); + return headers; + } + + private Brand saveBrand(String name, String description) { + return brandJpaRepository.save(Brand.create(name, description)); + } + + private Product saveProduct(Long brandId, String name) { + return productJpaRepository.save(Product.create(brandId, name, null, 10000, 10)); + } + + @DisplayName("브랜드 등록 시") + @Nested + class CreateBrand { + @DisplayName("유효한 요청이면, 201 Created 응답을 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + BrandV1Dto.CreateRequest request = new BrandV1Dto.CreateRequest("나이키", "스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("name이 누락되면, 400 Bad Request 응답을 반환한다.") + @Test + void returnsBadRequest_whenNameIsMissing() { + // arrange + BrandV1Dto.CreateRequest request = new BrandV1Dto.CreateRequest(null, "설명"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("브랜드 상세 조회 시") + @Nested + class GetBrand { + @DisplayName("존재하는 브랜드를 조회하면, 200 OK 응답을 반환한다.") + @Test + void returnsOk_whenBrandExists() { + // arrange + Brand saved = saveBrand("나이키", "스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 Not Found 응답을 반환한다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // arrange + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/999", HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("브랜드 수정 시") + @Nested + class UpdateBrand { + @DisplayName("유효한 요청이면, 200 OK 응답과 수정된 브랜드를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + Brand saved = saveBrand("나이키", "스포츠 브랜드"); + BrandV1Dto.UpdateRequest request = new BrandV1Dto.UpdateRequest("아디다스", "독일 스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.PUT, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("브랜드 삭제 시") + @Nested + class DeleteBrand { + @DisplayName("존재하는 브랜드를 삭제하면, 200 OK 응답을 반환하고 soft delete 처리된다.") + @Test + void returnsOk_andSoftDeletes() { + // arrange + Brand saved = saveBrand("나이키", "스포츠 브랜드"); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.DELETE, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.findById(saved.getId()).orElseThrow().getDeletedAt()).isNotNull() + ); + } + + @DisplayName("브랜드를 삭제하면, 소속 상품도 모두 soft delete 처리된다.") + @Test + void softDeletesAllProducts_whenBrandIsDeleted() { + // arrange + Brand brand = saveBrand("나이키", "스포츠 브랜드"); + Product product1 = saveProduct(brand.getId(), "에어맥스"); + Product product2 = saveProduct(brand.getId(), "조던"); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + testRestTemplate.exchange(ENDPOINT + "/" + brand.getId(), HttpMethod.DELETE, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(productJpaRepository.findById(product1.getId()).orElseThrow().getDeletedAt()).isNotNull(), + () -> assertThat(productJpaRepository.findById(product2.getId()).orElseThrow().getDeletedAt()).isNotNull() + ); + } + + @DisplayName("브랜드를 삭제하면, 소속 상품의 좋아요도 모두 hard delete 처리된다.") + @Test + void hardDeletesAllLikes_whenBrandIsDeleted() { + // arrange + Brand brand = saveBrand("나이키", "스포츠 브랜드"); + Product product = saveProduct(brand.getId(), "에어맥스"); + User user = userJpaRepository.save(UserFixture.builder().loginId("brandDeleteUser").build()); + likeJpaRepository.save(Like.create(user.getId(), product.getId())); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + testRestTemplate.exchange(ENDPOINT + "/" + brand.getId(), HttpMethod.DELETE, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(likeJpaRepository.findByUserIdAndProductId(user.getId(), product.getId())).isEmpty(); + } + } + + @DisplayName("브랜드 목록 조회 시") + @Nested + class GetBrands { + @DisplayName("등록된 브랜드 목록을 페이지 단위로 반환한다.") + @Test + void returnsPagedBrands() { + // arrange + Brand nike = saveBrand("나이키", "스포츠"); + Brand adidas = saveBrand("아디다스", "독일 스포츠"); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=0&size=20", HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + List ids = response.getBody() + .data() + .content() + .stream() + .map(BrandV1Dto.AdminBrandResponse::id) + .toList(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(nike.getId(), adidas.getId()) + ); + } + + @DisplayName("삭제된 브랜드는 목록에서 제외된다.") + @Test + void excludesDeletedBrands() { + // arrange + Brand nike = saveBrand("나이키", "스포츠"); + Brand toDelete = saveBrand("삭제브랜드", "삭제될 브랜드"); + toDelete.delete(); + brandJpaRepository.save(toDelete); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT + "?page=0&size=20", HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + List ids = response.getBody() + .data() + .content() + .stream() + .map(BrandV1Dto.AdminBrandResponse::id) + .toList(); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(nike.getId()), + () -> assertThat(ids).doesNotContain(toDelete.getId()) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java new file mode 100644 index 000000000..a51d37428 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java @@ -0,0 +1,156 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminOrderV1ApiE2ETest { + + private static final String USER_ENDPOINT = "/api/v1/orders"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/orders"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + private static final String RAW_PASSWORD = "TestPass1!"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @Autowired + public AdminOrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + private User savedUser; + private Product savedProduct; + + @BeforeEach + void setUp() { + String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + savedUser = userJpaRepository.save( + UserFixture.builder() + .loginId("adminOrderTestUser") + .password(encodedPassword) + .build() + ); + + Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); + savedProduct = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 150000, 10)); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, LDAP_VALUE); + return headers; + } + + private HttpHeaders userHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", savedUser.getLoginId()); + headers.set("X-Loopers-LoginPw", RAW_PASSWORD); + return headers; + } + + private Long createOrder() { + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 1)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders()); + ResponseEntity> response = + testRestTemplate.exchange(USER_ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + return response.getBody().data().id(); + } + + @DisplayName("어드민 주문 목록 조회 시") + @Nested + class GetOrders { + + @DisplayName("주문 목록을 조회하면, 200 OK와 페이징 결과를 반환한다.") + @Test + void returnsOk_withPagedOrders() { + // arrange + createOrder(); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ADMIN_ENDPOINT, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).isNotEmpty() + ); + } + } + + @DisplayName("어드민 주문 상세 조회 시") + @Nested + class GetOrder { + + @DisplayName("주문이 존재하면, 200 OK와 주문 상세를 반환한다.") + @Test + void returnsOk_whenOrderExists() { + // arrange + Long orderId = createOrder(); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ADMIN_ENDPOINT + "/" + orderId, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).isNotEmpty(), + () -> assertThat(response.getBody().data().items().get(0).productId()).isEqualTo(savedProduct.getId()) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java new file mode 100644 index 000000000..133fda0a6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java @@ -0,0 +1,455 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/products"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, LDAP_VALUE); + return headers; + } + + private Brand saveBrand(String name) { + return brandJpaRepository.save(Brand.create(name, null)); + } + + private Product saveProduct(Long brandId, String name, int price, int stock) { + return productJpaRepository.save(Product.create(brandId, name, null, price, stock)); + } + + @DisplayName("상품 등록 시") + @Nested + class CreateProduct { + + @DisplayName("유효한 요청이면, 201 Created와 등록된 상품을 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + String productName = "에어맥스"; + int productPrice = 150000; + Brand brand = saveBrand("나이키"); + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + brand.getId(), productName, "클래식 러닝화", productPrice, 10 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().name()).isEqualTo(productName), + () -> assertThat(response.getBody().data().price()).isEqualTo(productPrice), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brand.getId()) + ); + } + + @DisplayName("name이 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenNameIsMissing() { + // arrange + Brand brand = saveBrand("나이키"); + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + brand.getId(), null, null, 150000, 10 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("price가 0 이하이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenPriceIsZeroOrLess() { + // arrange + Brand brand = saveBrand("나이키"); + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + brand.getId(), "에어맥스", null, 0, 10 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("stockQuantity가 음수이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenStockQuantityIsNegative() { + // arrange + Brand brand = saveBrand("나이키"); + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + brand.getId(), "에어맥스", null, 150000, -1 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 브랜드에 등록하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // arrange + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + 999L, "에어맥스", null, 150000, 10 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드에 등록하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenBrandIsDeleted() { + // arrange + Brand brand = saveBrand("나이키"); + brand.delete(); + brandJpaRepository.save(brand); + + ProductV1Dto.CreateRequest request = new ProductV1Dto.CreateRequest( + brand.getId(), "에어맥스", null, 150000, 10 + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("상품 상세 조회 시") + @Nested + class GetProduct { + + @DisplayName("존재하는 상품을 조회하면, 200 OK와 상품 정보를 반환한다.") + @Test + void returnsOk_whenProductExists() { + // arrange + String productName = "에어맥스"; + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), productName, 150000, 10); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(productName) + ); + } + + @DisplayName("어드민은 HIDDEN 상품도 조회할 수 있다.") + @Test + void returnsOk_whenProductIsHidden() { + // arrange + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "숨김상품", 150000, 10); + saved.changeVisibility(Product.Visibility.HIDDEN); + productJpaRepository.save(saved); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // arrange + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.GET, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회 시") + @Nested + class GetProducts { + + @DisplayName("등록된 상품 목록을 페이지 단위로 반환한다.") + @Test + void returnsPagedProducts() { + // arrange + Brand brand = saveBrand("나이키"); + Product productA = saveProduct(brand.getId(), "에어맥스", 150000, 10); + Product productB = saveProduct(brand.getId(), "조던", 200000, 5); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?page=0&size=20", + HttpMethod.GET, entity, new ParameterizedTypeReference<>() {} + ); + + // assert + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.AdminProductResponse::id) + .toList(); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(productA.getId(), productB.getId()) + ); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void excludesDeletedProducts() { + // arrange + Brand brand = saveBrand("나이키"); + Product active = saveProduct(brand.getId(), "에어맥스", 150000, 10); + Product deleted = saveProduct(brand.getId(), "조던", 200000, 5); + deleted.delete(); + productJpaRepository.save(deleted); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?page=0&size=20", + HttpMethod.GET, entity, new ParameterizedTypeReference<>() {} + ); + + // assert + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.AdminProductResponse::id) + .toList(); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(active.getId()), + () -> assertThat(ids).doesNotContain(deleted.getId()) + ); + } + + @DisplayName("brandId로 필터링하면, 해당 브랜드 상품만 반환한다.") + @Test + void returnsFilteredByBrandId() { + // arrange + Brand nike = saveBrand("나이키"); + Brand adidas = saveBrand("아디다스"); + Product nikeProduct = saveProduct(nike.getId(), "에어맥스", 150000, 10); + Product adidasProduct = saveProduct(adidas.getId(), "슈퍼스타", 120000, 8); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, entity, new ParameterizedTypeReference<>() {} + ); + + // assert + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.AdminProductResponse::id) + .toList(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(nikeProduct.getId()), + () -> assertThat(ids).doesNotContain(adidasProduct.getId()) + ); + } + } + + @DisplayName("상품 수정 시") + @Nested + class UpdateProduct { + + @DisplayName("유효한 요청이면, 200 OK와 수정된 상품을 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + String updatedName = "에어맥스 v2"; + int updatedPrice = 180000; + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + ProductV1Dto.UpdateRequest request = new ProductV1Dto.UpdateRequest( + updatedName, "업데이트 버전", updatedPrice, 20, Product.Visibility.VISIBLE + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.PUT, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo(updatedName), + () -> assertThat(response.getBody().data().price()).isEqualTo(updatedPrice) + ); + } + + @DisplayName("존재하지 않는 상품을 수정하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // arrange + ProductV1Dto.UpdateRequest request = new ProductV1Dto.UpdateRequest( + "에어맥스 v2", null, 180000, 20, Product.Visibility.VISIBLE + ); + HttpEntity entity = new HttpEntity<>(request, adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.PUT, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("상품 삭제 시") + @Nested + class DeleteProduct { + + @DisplayName("존재하는 상품을 삭제하면, 200 OK를 반환하고 soft delete 처리된다.") + @Test + void returnsOk_andSoftDeletes() { + // arrange + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productJpaRepository.findById(saved.getId()).orElseThrow().getDeletedAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 상품을 삭제하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // arrange + HttpEntity entity = new HttpEntity<>(adminHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/999", + HttpMethod.DELETE, entity, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..fdca7ced7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,90 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.brand.dto.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("존재하는 브랜드를 조회하면, 200 OK 응답을 반환한다.") + @Test + void returnsOk_whenBrandExists() { + // arrange + Brand saved = brandJpaRepository.save(Brand.create("TEST_BRAND", "스포츠 브랜드")); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("TEST_BRAND"), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()) + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 Not Found 응답을 반환한다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/999", HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드를 조회하면, 404 Not Found 응답을 반환한다.") + @Test + void returnsNotFound_whenBrandIsDeleted() { + // arrange + Brand saved = brandJpaRepository.save(Brand.create("TEST_BRAND", "스포츠 브랜드")); + saved.delete(); + brandJpaRepository.save(saved); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + saved.getId(), HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..f56164598 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,309 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.like.LikeV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String PRODUCTS_ENDPOINT = "/api/v1/products"; + private static final String MY_LIKES_ENDPOINT = "/api/v1/users/me/likes"; + private static final String RAW_PASSWORD = "TestPass1!"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final LikeJpaRepository likeJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + LikeJpaRepository likeJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.likeJpaRepository = likeJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + private User savedUser; + private Product savedProduct; + + @BeforeEach + void setUp() { + String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + savedUser = userJpaRepository.save( + UserFixture.builder() + .loginId("likeTestUser") + .password(encodedPassword) + .build() + ); + + Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); + savedProduct = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 150000, 10)); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders userHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", savedUser.getLoginId()); + headers.set("X-Loopers-LoginPw", RAW_PASSWORD); + return headers; + } + + @DisplayName("좋아요 등록 시") + @Nested + class Register { + + @DisplayName("유효한 요청이면, 201 Created와 좋아요 정보를 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().productId()).isEqualTo(savedProduct.getId()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()) + ); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenDuplicate() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("HIDDEN 상품에 좋아요하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductIsHidden() { + // arrange + savedProduct.changeVisibility(Product.Visibility.HIDDEN); + productJpaRepository.save(savedProduct); + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + Long.MAX_VALUE + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenWrongPassword() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", savedUser.getLoginId()); + headers.set("X-Loopers-LoginPw", "WrongPass1!"); + HttpEntity entity = new HttpEntity<>(headers); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("좋아요 취소 시") + @Nested + class Cancel { + + @DisplayName("좋아요가 존재하면, 200 OK를 반환하고 좋아요가 삭제된다.") + @Test + void returnsOk_whenLikeExists() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + testRestTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.DELETE, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeJpaRepository.findByUserIdAndProductId(savedUser.getId(), savedProduct.getId())).isEmpty() + ); + } + + @DisplayName("좋아요하지 않은 상품에 취소 요청해도, 200 OK를 반환한다.") + @Test + void returnsOk_whenNotLiked() { + // arrange + String url = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + + // act + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.DELETE, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + @DisplayName("좋아요 목록 조회 시") + @Nested + class GetLikes { + + @DisplayName("좋아요 목록을 조회하면, 200 OK와 목록을 반환한다.") + @Test + void returnsOk_withLikes() { + // arrange + String likeUrl = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + testRestTemplate.exchange(likeUrl, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(MY_LIKES_ENDPOINT, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).productId()).isEqualTo(savedProduct.getId()) + ); + } + + @DisplayName("삭제된 상품의 좋아요는 목록에서 제외된다.") + @Test + void excludesDeletedProductFromLikesList() { + // arrange + String likeUrl = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + testRestTemplate.exchange(likeUrl, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + savedProduct.delete(); + productJpaRepository.save(savedProduct); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(MY_LIKES_ENDPOINT, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + + @DisplayName("HIDDEN 상태인 상품의 좋아요는 목록에서 제외된다.") + @Test + void excludesHiddenProductFromLikesList() { + // arrange + String likeUrl = PRODUCTS_ENDPOINT + "/" + savedProduct.getId() + "/likes"; + HttpEntity entity = new HttpEntity<>(userHeaders()); + testRestTemplate.exchange(likeUrl, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + savedProduct.changeVisibility(Product.Visibility.HIDDEN); + productJpaRepository.save(savedProduct); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(MY_LIKES_ENDPOINT, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + + @DisplayName("좋아요가 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + // arrange + HttpEntity entity = new HttpEntity<>(userHeaders()); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(MY_LIKES_ENDPOINT, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..44f7da800 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,336 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserFixture; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/orders"; + private static final String RAW_PASSWORD = "TestPass1!"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + private User savedUser; + private Product savedProduct; + + @BeforeEach + void setUp() { + String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + savedUser = userJpaRepository.save( + UserFixture.builder() + .loginId("orderTestUser") + .password(encodedPassword) + .build() + ); + + Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); + savedProduct = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 150000, 10)); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders userHeaders(User user, String rawPassword) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", user.getLoginId()); + headers.set("X-Loopers-LoginPw", rawPassword); + return headers; + } + + private String ordersUrlWithPeriod(ZonedDateTime startAt, ZonedDateTime endAt) { + return UriComponentsBuilder.fromPath(ENDPOINT) + .queryParam("startAt", startAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .queryParam("endAt", endAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .toUriString(); + } + + @DisplayName("주문 생성 시") + @Nested + class CreateOrder { + + @DisplayName("유효한 요청이면, 201 Created와 주문 정보를 반환한다.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 2)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + Product updated = productJpaRepository.findById(savedProduct.getId()).orElseThrow(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()), + () -> assertThat(updated.getStockQuantity()).isEqualTo(8) + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(99999L, 1)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("재고가 부족하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenInsufficientStock() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 999)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("주문 항목이 비어있으면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenItemsAreEmpty() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest(List.of()); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("수량이 0이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenQuantityIsZero() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 0)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("수량이 음수이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenQuantityIsNegative() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), -1)) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("중복 상품이 포함되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenDuplicateProducts() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of( + new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 1), + new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 2) + ) + ); + HttpEntity entity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("주문 목록 조회 시") + @Nested + class GetOrders { + + @DisplayName("주문이 있으면, 200 OK와 주문 목록을 반환한다.") + @Test + void returnsOk_withOrderList() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 1)) + ); + HttpEntity createEntity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, createEntity, new ParameterizedTypeReference<>() {}); + + String url = ordersUrlWithPeriod(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1)); + HttpEntity getEntity = new HttpEntity<>(userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, getEntity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isNotEmpty() + ); + } + + @DisplayName("주문이 없으면, 200 OK와 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoOrders() { + // arrange + String url = ordersUrlWithPeriod(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1)); + HttpEntity getEntity = new HttpEntity<>(userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, getEntity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).isEmpty() + ); + } + } + + @DisplayName("주문 상세 조회 시") + @Nested + class GetOrder { + + @DisplayName("주문이 존재하면, 200 OK와 주문 상세를 반환한다.") + @Test + void returnsOk_whenOrderExists() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 2)) + ); + HttpEntity createEntity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + ResponseEntity> created = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, createEntity, new ParameterizedTypeReference<>() {}); + + Long orderId = created.getBody().data().id(); + HttpEntity getEntity = new HttpEntity<>(userHeaders(savedUser, RAW_PASSWORD)); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + orderId, HttpMethod.GET, getEntity, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).isNotEmpty(), + () -> assertThat(response.getBody().data().items().get(0).productId()).isEqualTo(savedProduct.getId()) + ); + } + + @DisplayName("타인의 주문을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenOrderNotOwned() { + // arrange + OrderV1Dto.CreateRequest request = new OrderV1Dto.CreateRequest( + List.of(new OrderV1Dto.OrderItemRequest(savedProduct.getId(), 1)) + ); + HttpEntity createEntity = new HttpEntity<>(request, userHeaders(savedUser, RAW_PASSWORD)); + ResponseEntity> created = + testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, createEntity, new ParameterizedTypeReference<>() {}); + + User otherUser = userJpaRepository.save( + UserFixture.builder() + .loginId("orderOtherUser") + .password(bCryptPasswordEncoder.encode("OtherPass1!")) + .build() + ); + + Long orderId = created.getBody().data().id(); + HttpEntity getEntity = new HttpEntity<>(userHeaders(otherUser, "OtherPass1!")); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + orderId, HttpMethod.GET, getEntity, new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..4d5e51a1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,388 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Brand saveBrand(String name) { + return brandJpaRepository.save(Brand.create(name, null)); + } + + private Product saveProduct(Long brandId, String name, int price, int stock) { + return productJpaRepository.save(Product.create(brandId, name, null, price, stock)); + } + + @DisplayName("상품 상세 조회 시") + @Nested + class GetProduct { + + @DisplayName("존재하는 VISIBLE 상품을 조회하면, 200 OK와 상품 정보를 반환한다.") + @Test + void returnsOk_whenProductExists() { + // arrange + String productName = "에어맥스"; + int productPrice = 150000; + Brand brand = saveBrand("TEST_BRAND"); + Product saved = saveProduct(brand.getId(), productName, productPrice, 10); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(productName), + () -> assertThat(response.getBody().data().price()).isEqualTo(productPrice) + ); + } + + @DisplayName("상품 조회 시 brandName이 포함되어 반환된다.") + @Test + void returnsOk_withBrandName() { + // arrange + String brandName = "나이키"; + Brand brand = saveBrand(brandName); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brand().name()).isEqualTo(brandName) + ); + } + + @DisplayName("상품 조회 시 likeCount가 포함되어 반환된다") + @Test + void returnsOk_withLikeCountDefault() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/99999", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("삭제된 상품을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductIsDeleted() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + saved.delete(); + productJpaRepository.save(saved); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("HIDDEN 상품을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductIsHidden() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + saved.changeVisibility(Product.Visibility.HIDDEN); + productJpaRepository.save(saved); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회 시") + @Nested + class GetProducts { + + @DisplayName("상품 목록 조회 시 각 상품에 brandName이 포함되어 반환된다.") + @Test + void returnsOk_withBrandNameInList() { + // arrange + Brand nike = saveBrand("나이키"); + Brand adidas = saveBrand("아디다스"); + saveProduct(nike.getId(), "에어맥스", 150000, 10); + saveProduct(adidas.getId(), "슈퍼스타", 120000, 8); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()) + .extracting(p -> p.brand().name()) + .containsExactlyInAnyOrder("나이키", "아디다스") + ); + } + + @DisplayName("파라미터 없이 조회하면, 200 OK와 상품 목록을 반환한다.") + @Test + void returnsOk_withDefaultParams() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product productA = saveProduct(brand.getId(), "에어맥스", 150000, 10); + Product productB = saveProduct(brand.getId(), "조던", 200000, 5); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(productA.getId(), productB.getId()) + ); + } + + @DisplayName("brandId로 필터링하면, 해당 브랜드 상품만 반환한다.") + @Test + void returnsFilteredByBrandId() { + // arrange + Brand nike = saveBrand("나이키"); + Brand adidas = saveBrand("아디다스"); + Product nikeProduct = saveProduct(nike.getId(), "에어맥스", 150000, 10); + Product adidasProduct = saveProduct(adidas.getId(), "슈퍼스타", 120000, 8); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?brandId=" + nike.getId(), + HttpMethod.GET, null, new ParameterizedTypeReference<>() {} + ); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(nikeProduct.getId()), + () -> assertThat(ids).doesNotContain(adidasProduct.getId()) + ); + } + + @DisplayName("sort=PRICE_ASC로 조회하면, 가격 낮은순으로 반환한다.") + @Test + void returnsSortedByPriceAsc() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product expensiveProduct = saveProduct(brand.getId(), "비싼신발", 300000, 5); + Product cheapProduct = saveProduct(brand.getId(), "싼신발", 50000, 10); + Product middleProduct = saveProduct(brand.getId(), "중간신발", 150000, 7); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?sort=PRICE_ASC", + HttpMethod.GET, null, new ParameterizedTypeReference<>() {} + ); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids.indexOf(cheapProduct.getId())) + .isLessThan(ids.indexOf(middleProduct.getId())) + .isLessThan(ids.indexOf(expensiveProduct.getId())) + ); + } + + @DisplayName("sort=LIKES_DESC로 조회하면, 좋아요 많은순으로 반환한다.") + @Test + void returnsSortedByLikesDesc() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product lowLikes = saveProduct(brand.getId(), "좋아요적은상품", 100000, 10); + Product middleLikes = saveProduct(brand.getId(), "좋아요중간상품", 120000, 10); + Product highLikes = saveProduct(brand.getId(), "좋아요많은상품", 140000, 10); + + lowLikes.increaseLikeCount(); + middleLikes.increaseLikeCount(); + middleLikes.increaseLikeCount(); + highLikes.increaseLikeCount(); + highLikes.increaseLikeCount(); + highLikes.increaseLikeCount(); + + productJpaRepository.save(lowLikes); + productJpaRepository.save(middleLikes); + productJpaRepository.save(highLikes); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + ENDPOINT + "?sort=LIKES_DESC", + HttpMethod.GET, null, new ParameterizedTypeReference<>() {} + ); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids.indexOf(highLikes.getId())) + .isLessThan(ids.indexOf(middleLikes.getId())) + .isLessThan(ids.indexOf(lowLikes.getId())) + ); + } + + @DisplayName("삭제된 상품은 목록에서 제외된다.") + @Test + void excludesDeletedProducts() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product visibleProduct = saveProduct(brand.getId(), "정상상품", 100000, 10); + Product deletedProduct = saveProduct(brand.getId(), "삭제상품", 50000, 5); + deletedProduct.delete(); + productJpaRepository.save(deletedProduct); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(visibleProduct.getId()), + () -> assertThat(ids).doesNotContain(deletedProduct.getId()) + ); + } + + @DisplayName("HIDDEN 상품은 목록에서 제외된다.") + @Test + void excludesHiddenProducts() { + // arrange + Brand brand = saveBrand("TEST_BRAND"); + Product visibleProduct1 = saveProduct(brand.getId(), "노출상품1", 100000, 10); + Product visibleProduct2 = saveProduct(brand.getId(), "노출상품2", 200000, 10); + Product hiddenProduct = saveProduct(brand.getId(), "숨김상품", 50000, 5); + hiddenProduct.changeVisibility(Product.Visibility.HIDDEN); + productJpaRepository.save(hiddenProduct); + + // act + ResponseEntity>> response = + testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}); + + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(visibleProduct1.getId(), visibleProduct2.getId()), + () -> assertThat(ids).doesNotContain(hiddenProduct.getId()) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 66db6ea66..a40231c82 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -54,18 +54,25 @@ void tearDown() { @Nested class GetMyInfo { + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + private static final String RAW_PASSWORD = "TestPass1!"; + @DisplayName("존재하는 사용자를 조회하면, 200 OK와 마스킹된 이름을 반환한다.") @Test void returnsOk_whenUserExists() { // arrange - User savedUser = UserFixture.builder() - .loginId("testUser123") - .name("박자바") - .build(); - userJpaRepository.save(savedUser); + String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + User savedUser = userJpaRepository.save( + UserFixture.builder() + .loginId("testUser123") + .name("박자바") + .password(encodedPassword) + .build() + ); HttpHeaders headers = new HttpHeaders(); headers.set("X-Loopers-LoginId", savedUser.getLoginId()); + headers.set("X-Loopers-LoginPw", RAW_PASSWORD); HttpEntity requestEntity = new HttpEntity<>(headers); // act @@ -162,7 +169,7 @@ void returnsNotFound_whenUserNotExists() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } - @DisplayName("현재 비밀번호가 일치하지 않으면, 400 Bad Request를 반환한다.") + @DisplayName("현재 비밀번호가 일치하지 않으면, 404 Not Found를 반환한다.") @Test void returnsBadRequest_whenCurrentPasswordNotMatches() { // arrange @@ -189,7 +196,7 @@ void returnsBadRequest_whenCurrentPasswordNotMatches() { testRestTemplate.exchange(ENDPOINT_UPDATE_PASSWORD, HttpMethod.PATCH, requestEntity, responseType); // assert - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } @DisplayName("현재 비밀번호와 동일한 비밀번호로 변경하면, 400 Bad Request를 반환한다.") diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..461b8933d --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,21 @@ +### 좋아요 등록 +# POST /api/v1/products/{productId}/likes +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testUser123 +X-Loopers-LoginPw: ValidPass1! + +### + +### 좋아요 취소 +# DELETE /api/v1/products/{productId}/likes +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testUser123 +X-Loopers-LoginPw: ValidPass1! + +### + +### 내 좋아요 목록 조회 +# GET /api/v1/users/me/likes +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testUser123 +X-Loopers-LoginPw: ValidPass1!